From 5e95a1f7af841f10646133ad0cc155a2c5cea9fd Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 26 Jun 2022 03:12:58 +0900
Subject: [PATCH] refactor(client): extract interval logic to a composable
 function
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

あと`onUnmounted`を`onMounted`内で呼んでいたりしたのを修正したりとか
---
 packages/client/src/components/form/input.vue | 73 ++++++++++---------
 packages/client/src/components/form/range.vue | 24 +++---
 .../client/src/components/form/select.vue     | 57 ++++++++-------
 .../client/src/components/global/time.vue     | 18 ++---
 packages/client/src/components/mini-chart.vue |  8 +-
 .../client/src/components/notification.vue    | 17 +++--
 .../client/src/components/notifications.vue   | 10 ++-
 packages/client/src/components/poll.vue       | 14 ++--
 packages/client/src/components/sparkle.vue    | 17 +++--
 .../src/pages/admin/overview.federation.vue   | 13 +---
 packages/client/src/scripts/use-interval.ts   | 22 ++++++
 packages/client/src/widgets/aichan.vue        | 31 ++++----
 packages/client/src/widgets/calendar.vue      | 25 +++----
 packages/client/src/widgets/federation.vue    | 10 +--
 packages/client/src/widgets/online-users.vue  | 12 ++-
 packages/client/src/widgets/rss.vue           | 12 ++-
 packages/client/src/widgets/slideshow.vue     | 15 ++--
 packages/client/src/widgets/trends.vue        | 12 ++-
 18 files changed, 207 insertions(+), 183 deletions(-)
 create mode 100644 packages/client/src/scripts/use-interval.ts

diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue
index 7165671af3..5065e28892 100644
--- a/packages/client/src/components/form/input.vue
+++ b/packages/client/src/components/form/input.vue
@@ -3,7 +3,8 @@
 	<div class="label" @click="focus"><slot name="label"></slot></div>
 	<div class="input" :class="{ inline, disabled, focused }">
 		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
-		<input ref="inputEl"
+		<input
+			ref="inputEl"
 			v-model="v"
 			v-adaptive-border
 			:type="type"
@@ -34,8 +35,9 @@
 
 <script lang="ts">
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import MkButton from '@/components/ui/button.vue';
 import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/ui/button.vue';
+import { useInterval } from '@/scripts/use-interval';
 
 export default defineComponent({
 	components: {
@@ -44,45 +46,45 @@ export default defineComponent({
 
 	props: {
 		modelValue: {
-			required: true
+			required: true,
 		},
 		type: {
 			type: String,
-			required: false
+			required: false,
 		},
 		required: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		readonly: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		disabled: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		pattern: {
 			type: String,
-			required: false
+			required: false,
 		},
 		placeholder: {
 			type: String,
-			required: false
+			required: false,
 		},
 		autofocus: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		autocomplete: {
-			required: false
+			required: false,
 		},
 		spellcheck: {
-			required: false
+			required: false,
 		},
 		step: {
-			required: false
+			required: false,
 		},
 		datalist: {
 			type: Array,
@@ -91,17 +93,17 @@ export default defineComponent({
 		inline: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		debounce: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		manualSave: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 	},
 
@@ -134,7 +136,7 @@ export default defineComponent({
 
 		const updated = () => {
 			changed.value = false;
-			if (type?.value === 'number') {
+			if (type.value === 'number') {
 				context.emit('update:modelValue', parseFloat(v.value));
 			} else {
 				context.emit('update:modelValue', v.value);
@@ -159,30 +161,29 @@ export default defineComponent({
 			invalid.value = inputEl.value.validity.badInput;
 		});
 
+		// このコンポーネントが作成された時、非表示状態である場合がある
+		// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+		useInterval(() => {
+			if (prefixEl.value) {
+				if (prefixEl.value.offsetWidth) {
+					inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+				}
+			}
+			if (suffixEl.value) {
+				if (suffixEl.value.offsetWidth) {
+					inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+				}
+			}
+		}, 100, {
+			immediate: true,
+			afterMounted: true,
+		});
+
 		onMounted(() => {
 			nextTick(() => {
 				if (autofocus.value) {
 					focus();
 				}
-
-				// このコンポーネントが作成された時、非表示状態である場合がある
-				// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
-				const clock = window.setInterval(() => {
-					if (prefixEl.value) {
-						if (prefixEl.value.offsetWidth) {
-							inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
-						}
-					}
-					if (suffixEl.value) {
-						if (suffixEl.value.offsetWidth) {
-							inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
-						}
-					}
-				}, 100);
-
-				onUnmounted(() => {
-					window.clearInterval(clock);
-				});
 			});
 		});
 
diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue
index 9bf7651119..221ad029a7 100644
--- a/packages/client/src/components/form/range.vue
+++ b/packages/client/src/components/form/range.vue
@@ -24,31 +24,31 @@ export default defineComponent({
 		modelValue: {
 			type: Number,
 			required: false,
-			default: 0
+			default: 0,
 		},
 		disabled: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		min: {
 			type: Number,
 			required: false,
-			default: 0
+			default: 0,
 		},
 		max: {
 			type: Number,
 			required: false,
-			default: 100
+			default: 100,
 		},
 		step: {
 			type: Number,
 			required: false,
-			default: 1
+			default: 1,
 		},
 		autofocus: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		textConverter: {
 			type: Function,
@@ -90,14 +90,18 @@ export default defineComponent({
 			}
 		};
 		watch([steppedValue, containerEl], calcThumbPosition);
+
+		let ro: ResizeObserver | undefined;
+
 		onMounted(() => {
-			const ro = new ResizeObserver((entries, observer) => {
+			ro = new ResizeObserver((entries, observer) => {
 				calcThumbPosition();
 			});
 			ro.observe(containerEl.value);
-			onUnmounted(() => {
-				ro.disconnect();
-			});
+		});
+		
+		onUnmounted(() => {
+			if (ro) ro.disconnect();
 		});
 
 		const steps = computed(() => {
diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue
index 87196027a8..7f5f8784b6 100644
--- a/packages/client/src/components/form/select.vue
+++ b/packages/client/src/components/form/select.vue
@@ -3,7 +3,8 @@
 	<div class="label" @click="focus"><slot name="label"></slot></div>
 	<div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
 		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
-		<select ref="inputEl"
+		<select
+			ref="inputEl"
 			v-model="v"
 			v-adaptive-border
 			class="select"
@@ -29,6 +30,7 @@
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 export default defineComponent({
 	components: {
@@ -37,38 +39,38 @@ export default defineComponent({
 
 	props: {
 		modelValue: {
-			required: true
+			required: true,
 		},
 		required: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		readonly: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		disabled: {
 			type: Boolean,
-			required: false
+			required: false,
 		},
 		placeholder: {
 			type: String,
-			required: false
+			required: false,
 		},
 		autofocus: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		inline: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		manualSave: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 	},
 
@@ -109,30 +111,29 @@ export default defineComponent({
 			invalid.value = inputEl.value.validity.badInput;
 		});
 
+		// このコンポーネントが作成された時、非表示状態である場合がある
+		// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+		useInterval(() => {
+			if (prefixEl.value) {
+				if (prefixEl.value.offsetWidth) {
+					inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+				}
+			}
+			if (suffixEl.value) {
+				if (suffixEl.value.offsetWidth) {
+					inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+				}
+			}
+		}, 100, {
+			immediate: true,
+			afterMounted: true,
+		});
+
 		onMounted(() => {
 			nextTick(() => {
 				if (autofocus.value) {
 					focus();
 				}
-
-				// このコンポーネントが作成された時、非表示状態である場合がある
-				// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
-				const clock = window.setInterval(() => {
-					if (prefixEl.value) {
-						if (prefixEl.value.offsetWidth) {
-							inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
-						}
-					}
-					if (suffixEl.value) {
-						if (suffixEl.value.offsetWidth) {
-							inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
-						}
-					}
-				}, 100);
-
-				onUnmounted(() => {
-					window.clearInterval(clock);
-				});
 			});
 		});
 
diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue
index a7f142f961..801490225b 100644
--- a/packages/client/src/components/global/time.vue
+++ b/packages/client/src/components/global/time.vue
@@ -24,14 +24,14 @@ let now = $ref(new Date());
 const relative = $computed(() => {
 	const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
 	return (
-		ago >= 31536000 ? i18n.t('_ago.yearsAgo',   { n: Math.round(ago / 31536000).toString() }) :
-		ago >= 2592000  ? i18n.t('_ago.monthsAgo',  { n: Math.round(ago / 2592000).toString() }) :
-		ago >= 604800   ? i18n.t('_ago.weeksAgo',   { n: Math.round(ago / 604800).toString() }) :
-		ago >= 86400    ? i18n.t('_ago.daysAgo',    { n: Math.round(ago / 86400).toString() }) :
-		ago >= 3600     ? i18n.t('_ago.hoursAgo',   { n: Math.round(ago / 3600).toString() }) :
-		ago >= 60       ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
-		ago >= 10       ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
-		ago >= -1       ? i18n.ts._ago.justNow :
+		ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
+		ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
+		ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) :
+		ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
+		ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
+		ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
+		ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
+		ago >= -1 ? i18n.ts._ago.justNow :
 		i18n.ts._ago.future);
 });
 
@@ -50,7 +50,7 @@ if (props.mode === 'relative' || props.mode === 'detail') {
 	tickId = window.requestAnimationFrame(tick);
 
 	onUnmounted(() => {
-		window.clearTimeout(tickId);
+		window.cancelAnimationFrame(tickId);
 	});
 }
 </script>
diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue
index 5e842b1975..c64ce163f9 100644
--- a/packages/client/src/components/mini-chart.vue
+++ b/packages/client/src/components/mini-chart.vue
@@ -29,6 +29,7 @@
 import { onUnmounted, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
 import tinycolor from 'tinycolor2';
+import { useInterval } from '@/scripts/use-interval';
 
 const props = defineProps<{
 	src: number[];
@@ -65,9 +66,8 @@ function draw(): void {
 watch(() => props.src, draw, { immediate: true });
 
 // Vueが何故かWatchを発動させない場合があるので
-clock = window.setInterval(draw, 1000);
-
-onUnmounted(() => {
-	window.clearInterval(clock);
+useInterval(draw, 1000, {
+	immediate: false,
+	afterMounted: true,
 });
 </script>
diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue
index cbfd809f37..26fbeecb68 100644
--- a/packages/client/src/components/notification.vue
+++ b/packages/client/src/components/notification.vue
@@ -112,9 +112,12 @@ export default defineComponent({
 		const elRef = ref<HTMLElement>(null);
 		const reactionRef = ref(null);
 
+		let readObserver: IntersectionObserver | undefined;
+		let connection;
+
 		onMounted(() => {
 			if (!props.notification.isRead) {
-				const readObserver = new IntersectionObserver((entries, observer) => {
+				readObserver = new IntersectionObserver((entries, observer) => {
 					if (!entries.some(entry => entry.isIntersecting)) return;
 					stream.send('readNotification', {
 						id: props.notification.id,
@@ -124,19 +127,19 @@ export default defineComponent({
 
 				readObserver.observe(elRef.value);
 
-				const connection = stream.useChannel('main');
+				connection = stream.useChannel('main');
 				connection.on('readAllNotifications', () => readObserver.disconnect());
 
 				watch(props.notification.isRead, () => {
 					readObserver.disconnect();
 				});
-
-				onUnmounted(() => {
-					readObserver.disconnect();
-					connection.dispose();
-				});
 			}
 		});
+		
+		onUnmounted(() => {
+			if (readObserver) readObserver.disconnect();
+			if (connection) connection.dispose();
+		});
 
 		const followRequestDone = ref(false);
 		const groupInviteDone = ref(false);
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
index 8eb569c369..eb19ad488c 100644
--- a/packages/client/src/components/notifications.vue
+++ b/packages/client/src/components/notifications.vue
@@ -60,8 +60,10 @@ const onNotification = (notification) => {
 	}
 };
 
+let connection;
+
 onMounted(() => {
-	const connection = stream.useChannel('main');
+	connection = stream.useChannel('main');
 	connection.on('notification', onNotification);
 	connection.on('readAllNotifications', () => {
 		if (pagingComponent.value) {
@@ -87,10 +89,10 @@ onMounted(() => {
 			}
 		}
 	});
+});
 
-	onUnmounted(() => {
-		connection.dispose();
-	});
+onUnmounted(() => {
+	if (connection) connection.dispose();
 });
 </script>
 
diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue
index d9ef5970cb..35f87325d8 100644
--- a/packages/client/src/components/poll.vue
+++ b/packages/client/src/components/poll.vue
@@ -27,18 +27,19 @@ import { sum } from '@/scripts/array';
 import { pleaseLogin } from '@/scripts/please-login';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
+import { useInterval } from '@/scripts/use-interval';
 
 export default defineComponent({
 	props: {
 		note: {
 			type: Object,
-			required: true
+			required: true,
 		},
 		readOnly: {
 			type: Boolean,
 			required: false,
 			default: false,
-		}
+		},
 	},
 
 	setup(props) {
@@ -54,7 +55,7 @@ export default defineComponent({
 				s: Math.floor(remaining.value % 60),
 				m: Math.floor(remaining.value / 60) % 60,
 				h: Math.floor(remaining.value / 3600) % 24,
-				d: Math.floor(remaining.value / 86400)
+				d: Math.floor(remaining.value / 86400),
 			}));
 
 		const showResult = ref(props.readOnly || isVoted.value);
@@ -68,10 +69,9 @@ export default defineComponent({
 				}
 			};
 
-			tick();
-			const intevalId = window.setInterval(tick, 3000);
-			onUnmounted(() => {
-				window.clearInterval(intevalId);
+			useInterval(tick, 3000, {
+				immediate: true,
+				afterMounted: false,
 			});
 		}
 
diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue
index f52e5a3f9b..b52dbe31c4 100644
--- a/packages/client/src/components/sparkle.vue
+++ b/packages/client/src/components/sparkle.vue
@@ -33,7 +33,8 @@
 	</svg>
 	-->
 	<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg">
-		<path style="transform-origin: center; transform-box: fill-box;"
+		<path
+			style="transform-origin: center; transform-box: fill-box;"
 			:transform="`translate(${particle.x} ${particle.y})`"
 			:fill="particle.color"
 			d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
@@ -73,14 +74,15 @@ export default defineComponent({
 		const width = ref(0);
 		const height = ref(0);
 		const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202'];
+		let stop = false;
+		let ro: ResizeObserver | undefined;
 
 		onMounted(() => {
-			const ro = new ResizeObserver((entries, observer) => {
+			ro = new ResizeObserver((entries, observer) => {
 				width.value = el.value?.offsetWidth + 64;
 				height.value = el.value?.offsetHeight + 64;
 			});
 			ro.observe(el.value);
-			let stop = false;
 			const add = () => {
 				if (stop) return;
 				const x = (Math.random() * (width.value - 64));
@@ -104,10 +106,11 @@ export default defineComponent({
 				}, 500 + (Math.random() * 500));
 			};
 			add();
-			onUnmounted(() => {
-				ro.disconnect();
-				stop = true;
-			});
+		});
+		
+		onUnmounted(() => {
+			if (ro) ro.disconnect();
+			stop = true;
 		});
 
 		return {
diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue
index 6da1fa4e98..6c99cad33c 100644
--- a/packages/client/src/pages/admin/overview.federation.vue
+++ b/packages/client/src/pages/admin/overview.federation.vue
@@ -18,6 +18,7 @@
 import { onMounted, onUnmounted, ref } from 'vue';
 import MkMiniChart from '@/components/mini-chart.vue';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 const instances = ref([]);
 const charts = ref([]);
@@ -34,15 +35,9 @@ const fetch = async () => {
 	fetching.value = false;
 };
 
-let intervalId;
-
-onMounted(() => {
-	fetch();
-	intervalId = window.setInterval(fetch, 1000 * 60);
-});
-
-onUnmounted(() => {
-	window.clearInterval(intervalId);
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
 });
 </script>
 
diff --git a/packages/client/src/scripts/use-interval.ts b/packages/client/src/scripts/use-interval.ts
new file mode 100644
index 0000000000..eb6e44338d
--- /dev/null
+++ b/packages/client/src/scripts/use-interval.ts
@@ -0,0 +1,22 @@
+import { onMounted, onUnmounted } from 'vue';
+
+export function useInterval(fn: () => void, interval: number, options: {
+	immediate: boolean;
+	afterMounted: boolean;
+}): void {
+	let intervalId: number | null = null;
+
+	if (options.afterMounted) {
+		onMounted(() => {
+			if (options.immediate) fn();
+			intervalId = window.setInterval(fn, interval);
+		});
+	} else {
+		if (options.immediate) fn();
+		intervalId = window.setInterval(fn, interval);
+	}
+
+	onUnmounted(() => {
+		if (intervalId) window.clearInterval(intervalId);
+	});
+}
diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue
index cdd367cc84..828490fd9c 100644
--- a/packages/client/src/widgets/aichan.vue
+++ b/packages/client/src/widgets/aichan.vue
@@ -6,8 +6,8 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, reactive, ref } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 
 const name = 'ai';
 
@@ -38,22 +38,23 @@ const touched = () => {
 	//if (this.live2d) this.live2d.changeExpression('gurugurume');
 };
 
-onMounted(() => {
-	const onMousemove = (ev: MouseEvent) => {
-		const iframeRect = live2d.value.getBoundingClientRect();
-		live2d.value.contentWindow.postMessage({
-			type: 'moveCursor',
-			body: {
-				x: ev.clientX - iframeRect.left,
-				y: ev.clientY - iframeRect.top,
-			}
-		}, '*');
-	};
+const onMousemove = (ev: MouseEvent) => {
+	const iframeRect = live2d.value.getBoundingClientRect();
+	live2d.value.contentWindow.postMessage({
+		type: 'moveCursor',
+		body: {
+			x: ev.clientX - iframeRect.left,
+			y: ev.clientY - iframeRect.top,
+		},
+	}, '*');
+};
 
+onMounted(() => {
 	window.addEventListener('mousemove', onMousemove, { passive: true });
-	onUnmounted(() => {
-		window.removeEventListener('mousemove', onMousemove);
-	});
+});
+
+onUnmounted(() => {
+	window.removeEventListener('mousemove', onMousemove);
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue
index 2a2b035541..3a0dc8970c 100644
--- a/packages/client/src/widgets/calendar.vue
+++ b/packages/client/src/widgets/calendar.vue
@@ -34,9 +34,10 @@
 
 <script lang="ts" setup>
 import { onUnmounted, ref } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import { i18n } from '@/i18n';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'calendar';
 
@@ -85,28 +86,26 @@ const tick = () => {
 		i18n.ts._weekday.wednesday,
 		i18n.ts._weekday.thursday,
 		i18n.ts._weekday.friday,
-		i18n.ts._weekday.saturday
+		i18n.ts._weekday.saturday,
 	][now.getDay()];
 
-	const dayNumer   = now.getTime() - new Date(ny, nm, nd).getTime();
-	const dayDenom   = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
+	const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
+	const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
 	const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
 	const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
-	const yearNumer  = now.getTime() - new Date(ny, 0, 1).getTime();
-	const yearDenom  = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
+	const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
+	const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
 
-	dayP.value   = dayNumer   / dayDenom   * 100;
+	dayP.value = dayNumer / dayDenom * 100;
 	monthP.value = monthNumer / monthDenom * 100;
-	yearP.value  = yearNumer  / yearDenom  * 100;
+	yearP.value = yearNumer / yearDenom * 100;
 
 	isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
 };
 
-tick();
-
-const intervalId = window.setInterval(tick, 1000);
-onUnmounted(() => {
-	window.clearInterval(intervalId);
+useInterval(tick, 1000, {
+	immediate: true,
+	afterMounted: false,
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue
index afe7af0e96..ac87cdac2e 100644
--- a/packages/client/src/widgets/federation.vue
+++ b/packages/client/src/widgets/federation.vue
@@ -25,6 +25,7 @@ import { GetFormResultType } from '@/scripts/form';
 import MkContainer from '@/components/ui/container.vue';
 import MkMiniChart from '@/components/mini-chart.vue';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'federation';
 
@@ -64,12 +65,9 @@ const fetch = async () => {
 	fetching.value = false;
 };
 
-onMounted(() => {
-	fetch();
-	const intervalId = window.setInterval(fetch, 1000 * 60);
-	onUnmounted(() => {
-		window.clearInterval(intervalId);
-	});
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue
index eb3184fe9d..4122a82657 100644
--- a/packages/client/src/widgets/online-users.vue
+++ b/packages/client/src/widgets/online-users.vue
@@ -8,9 +8,10 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'onlineUsers';
 
@@ -43,12 +44,9 @@ const tick = () => {
 	});
 };
 
-onMounted(() => {
-	tick();
-	const intervalId = window.setInterval(tick, 1000 * 15);
-	onUnmounted(() => {
-		window.clearInterval(intervalId);
-	});
+useInterval(tick, 1000 * 15, {
+	immediate: true,
+	afterMounted: true,
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue
index fc65f11813..e5da291a8d 100644
--- a/packages/client/src/widgets/rss.vue
+++ b/packages/client/src/widgets/rss.vue
@@ -14,10 +14,11 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import * as os from '@/os';
 import MkContainer from '@/components/ui/container.vue';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'rss';
 
@@ -60,12 +61,9 @@ const tick = () => {
 
 watch(() => widgetProps.url, tick);
 
-onMounted(() => {
-	tick();
-	const intervalId = window.setInterval(tick, 60000);
-	onUnmounted(() => {
-		window.clearInterval(intervalId);
-	});
+useInterval(tick, 60000, {
+	immediate: true,
+	afterMounted: true,
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue
index fd78edbe40..c286312161 100644
--- a/packages/client/src/widgets/slideshow.vue
+++ b/packages/client/src/widgets/slideshow.vue
@@ -13,9 +13,10 @@
 
 <script lang="ts" setup>
 import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'slideshow';
 
@@ -75,7 +76,7 @@ const fetch = () => {
 	os.api('drive/files', {
 		folderId: widgetProps.folderId,
 		type: 'image/*',
-		limit: 100
+		limit: 100,
 	}).then(res => {
 		images.value = res;
 		fetching.value = false;
@@ -96,15 +97,15 @@ const choose = () => {
 	});
 };
 
+useInterval(change, 10000, {
+	immediate: false,
+	afterMounted: true,
+});
+
 onMounted(() => {
 	if (widgetProps.folderId != null) {
 		fetch();
 	}
-
-	const intervalId = window.setInterval(change, 10000);
-	onUnmounted(() => {
-		window.clearInterval(intervalId);
-	});
 });
 
 defineExpose<WidgetComponentExpose>({
diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue
index 9680f1c892..0f34ea6341 100644
--- a/packages/client/src/widgets/trends.vue
+++ b/packages/client/src/widgets/trends.vue
@@ -19,11 +19,12 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
 import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
 import MkContainer from '@/components/ui/container.vue';
 import MkMiniChart from '@/components/mini-chart.vue';
 import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
 
 const name = 'hashtags';
 
@@ -58,12 +59,9 @@ const fetch = () => {
 	});
 };
 
-onMounted(() => {
-	fetch();
-	const intervalId = window.setInterval(fetch, 1000 * 60);
-	onUnmounted(() => {
-		window.clearInterval(intervalId);
-	});
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
 });
 
 defineExpose<WidgetComponentExpose>({