1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 19:04:46 +00:00

feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories

This commit is contained in:
2026-02-14 21:38:29 +07:00
parent 7dce7ed482
commit 6565fa3de8
64 changed files with 1564 additions and 55 deletions

View File

@@ -0,0 +1,3 @@
export * from './unrefElement';
export * from './useRenderCount';
export * from './useRenderInfo';

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils'
import { unrefElement } from '.';
describe('unrefElement', () => {
it('returns a plain element when passed a raw element', () => {
const htmlEl = document.createElement('div');
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
expect(unrefElement(htmlEl)).toBe(htmlEl);
expect(unrefElement(svgEl)).toBe(svgEl);
});
it('returns element when passed a ref or shallowRef to an element', () => {
const el = document.createElement('div');
const elRef = ref<HTMLElement | null>(el);
const shallowElRef = shallowRef<HTMLElement | null>(el);
expect(unrefElement(elRef)).toBe(el);
expect(unrefElement(shallowElRef)).toBe(el);
});
it('returns element when passed a computed ref or getter function', () => {
const el = document.createElement('div');
const computedElRef = computed(() => el);
const elGetter = () => el;
expect(unrefElement(computedElRef)).toBe(el);
expect(unrefElement(elGetter)).toBe(el);
});
it('returns component $el when passed a component instance', async () => {
const Child = defineComponent({
template: `<span class="child-el">child</span>`,
});
const Parent = defineComponent({
components: { Child },
template: `<Child ref="childRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
const childInstance = (wrapper.vm as any).$refs.childRef;
const result = unrefElement(childInstance);
expect(result).toBe(childInstance.$el);
expect((result as HTMLElement).classList.contains('child-el')).toBe(true);
});
it('handles null and undefined values', () => {
expect(unrefElement(undefined)).toBe(undefined);
expect(unrefElement(null)).toBe(null);
expect(unrefElement(ref(null))).toBe(null);
expect(unrefElement(ref(undefined))).toBe(undefined);
expect(unrefElement(() => null)).toBe(null);
expect(unrefElement(() => undefined)).toBe(undefined);
});
});

View File

@@ -0,0 +1,33 @@
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
import { toValue } from 'vue';
export type VueInstance = ComponentPublicInstance;
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
/**
* @name unrefElement
* @category Component
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
*
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
*
* @example
* const element = useTemplateRef<HTMLElement>('element');
* const result = unrefElement(element); // result is the element instance
*
* @example
* const component = useTemplateRef<Component>('component');
* const result = unrefElement(component); // result is the component instance
*
* @since 0.0.11
*/
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
const plain = toValue(elRef);
return (plain as VueInstance)?.$el ?? plain;
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils'
import { useRenderCount } from '.';
const ComponentStub = defineComponent({
setup() {
const count = useRenderCount();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { count, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderCount', () => {
it('return the number of times the component has been rendered', async () => {
const component = mount(ComponentStub);
// Initial render
expect(component.vm.count).toBe(1);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(component.vm.count).toBe(1);
expect(component.text()).toBe('0');
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(component.vm.count).toBe(2);
expect(component.text()).toBe('1');
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(component.vm.count).toBe(3);
expect(component.text()).toBe('3');
});
it('can be used with a specific component instance', async () => {
const component = mount(ComponentStub);
const instance = component.vm.$;
const count = useRenderCount(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(count.value).toBe(0);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(count.value).toBe(0);
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(count.value).toBe(1);
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(count.value).toBe(2);
});
});

View File

@@ -0,0 +1,29 @@
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
import { useCounter } from '@/composables/state/useCounter';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderCount
* @category Component
* @description Returns the number of times the component has been rendered into the DOM
*
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
* @returns {Readonly<Ref<number>>} The number of times the component has been rendered
*
* @example
* const count = useRenderCount();
*
* @example
* const count = useRenderCount(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderCount(instance?: ComponentInternalInstance) {
const { count, increment } = useCounter(0);
const target = getLifeCycleTarger(instance);
onMounted(increment, target);
onUpdated(increment, target);
return readonly(count);
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { useRenderInfo } from '.';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
const NamedComponentStub = defineComponent({
name: 'ComponentStub',
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
const UnnamedComponentStub = defineComponent({
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderInfo', () => {
it('return uid if component name is not available', async () => {
const wrapper = mount(UnnamedComponentStub);
expect(wrapper.vm.info.component).toBe(wrapper.vm.$.uid);
});
it('return render info for the given instance', async () => {
const wrapper = mount(NamedComponentStub);
// Initial render
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBeGreaterThan(0);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
let lastRendered = wrapper.vm.info.lastRendered;
let duration = wrapper.vm.info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBe(duration);
expect(wrapper.vm.info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(2);
expect(wrapper.vm.info.duration.value).not.toBe(duration);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
});
it('can be used with a specific component instance', async () => {
const wrapper = mount(NamedComponentStub);
const instance = wrapper.vm.$;
const info = useRenderInfo(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(0);
expect(info.lastRendered).toBeGreaterThan(0);
let lastRendered = info.lastRendered;
let duration = info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(duration);
expect(info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(1);
expect(info.duration.value).not.toBe(duration);
expect(info.lastRendered).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,45 @@
import { timestamp } from '@robonen/stdlib';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
import { useRenderCount } from '../useRenderCount';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderInfo
* @category Component
* @description Returns information about the component's render count and the last time it was rendered
*
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
*
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo();
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderInfo(instance?: ComponentInternalInstance) {
const target = getLifeCycleTarger(instance);
const duration = ref(0);
let renderStartTime = 0;
const startMark = () => renderStartTime = performance.now();
const endMark = () => {
duration.value = Math.max(performance.now() - renderStartTime, 0);
renderStartTime = 0;
};
onBeforeMount(startMark, target);
onMounted(endMark, target);
onBeforeUpdate(startMark, target);
onUpdated(endMark, target);
return {
component: target?.type.name ?? target?.uid,
count: useRenderCount(instance),
duration: readonly(duration),
lastRendered: timestamp(),
};
}