mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, watch } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { broadcastedRef } from '.';
|
||||
|
||||
type MessageHandler = ((event: MessageEvent) => void) | null;
|
||||
|
||||
class MockBroadcastChannel {
|
||||
static instances: MockBroadcastChannel[] = [];
|
||||
|
||||
name: string;
|
||||
onmessage: MessageHandler = null;
|
||||
closed = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
MockBroadcastChannel.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(data: unknown) {
|
||||
if (this.closed) return;
|
||||
|
||||
for (const instance of MockBroadcastChannel.instances) {
|
||||
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
|
||||
instance.onmessage(new MessageEvent('message', { data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
const index = MockBroadcastChannel.instances.indexOf(this);
|
||||
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithRef = (setup: () => Record<string, any> | void) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(broadcastedRef, () => {
|
||||
let component: ReturnType<typeof mountWithRef>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockBroadcastChannel.instances = [];
|
||||
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('create a ref with the initial value', () => {
|
||||
component = mountWithRef(() => {
|
||||
const count = broadcastedRef('test-key', 42);
|
||||
expect(count.value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcast value changes to other channels with the same key', () => {
|
||||
const ref1 = broadcastedRef('shared', 0);
|
||||
const ref2 = broadcastedRef('shared', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(100);
|
||||
});
|
||||
|
||||
it('not broadcast to channels with a different key', () => {
|
||||
const ref1 = broadcastedRef('key-a', 0);
|
||||
const ref2 = broadcastedRef('key-b', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(0);
|
||||
});
|
||||
|
||||
it('receive values from other channels and trigger reactivity', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
component = mountWithRef(() => {
|
||||
const data = broadcastedRef('reactive-test', 'initial');
|
||||
watch(data, callback, { flush: 'sync' });
|
||||
});
|
||||
|
||||
const sender = broadcastedRef('reactive-test', '');
|
||||
sender.value = 'updated';
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
|
||||
});
|
||||
|
||||
it('not broadcast initial value by default', () => {
|
||||
const ref1 = broadcastedRef('no-immediate', 'first');
|
||||
const ref2 = broadcastedRef('no-immediate', 'second');
|
||||
|
||||
expect(ref1.value).toBe('first');
|
||||
expect(ref2.value).toBe('second');
|
||||
});
|
||||
|
||||
it('broadcast initial value when immediate is true', () => {
|
||||
const ref1 = broadcastedRef('immediate-test', 'existing');
|
||||
broadcastedRef('immediate-test', 'new-value', { immediate: true });
|
||||
|
||||
expect(ref1.value).toBe('new-value');
|
||||
});
|
||||
|
||||
it('close channel on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
broadcastedRef('dispose-test', 0);
|
||||
});
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handle complex object values via structured clone', () => {
|
||||
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
|
||||
ref1.value = { status: 'paid', amount: 99.99 };
|
||||
|
||||
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
|
||||
});
|
||||
|
||||
it('fallback to a regular ref when BroadcastChannel is not available', () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
|
||||
const data = broadcastedRef('fallback', 'value');
|
||||
|
||||
expect(data.value).toBe('value');
|
||||
|
||||
data.value = 'updated';
|
||||
expect(data.value).toBe('updated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { customRef, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface BroadcastedRefOptions {
|
||||
/**
|
||||
* Immediately broadcast the initial value to other tabs on creation
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name broadcastedRef
|
||||
* @category Reactivity
|
||||
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||
*
|
||||
* @param {string} key The channel key to use for broadcasting
|
||||
* @param {T} initialValue The initial value of the ref
|
||||
* @param {BroadcastedRefOptions} [options={}] Options
|
||||
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||
*
|
||||
* @example
|
||||
* const count = broadcastedRef('counter', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = broadcastedRef('payment-status', { status: 'pending' });
|
||||
*
|
||||
* @since 0.0.13
|
||||
*/
|
||||
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
|
||||
const { immediate = false } = options;
|
||||
|
||||
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
|
||||
return ref(initialValue) as Ref<T>;
|
||||
}
|
||||
|
||||
const channel = new BroadcastChannel(key);
|
||||
let value = initialValue;
|
||||
|
||||
const data = customRef<T>((track, trigger) => {
|
||||
channel.onmessage = (event: MessageEvent<T>) => {
|
||||
value = event.data;
|
||||
trigger();
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
track();
|
||||
return value;
|
||||
},
|
||||
set(newValue: T) {
|
||||
value = newValue;
|
||||
channel.postMessage(newValue);
|
||||
trigger();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (immediate) {
|
||||
channel.postMessage(initialValue);
|
||||
}
|
||||
|
||||
tryOnScopeDispose(() => channel.close());
|
||||
|
||||
return data;
|
||||
}
|
||||
4
vue/toolkit/src/composables/reactivity/index.ts
Normal file
4
vue/toolkit/src/composables/reactivity/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './broadcastedRef';
|
||||
export * from './useCached';
|
||||
export * from './useLastChanged';
|
||||
export * from './useSyncRefs';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref, nextTick, reactive } from 'vue';
|
||||
import { useCached } from '.';
|
||||
|
||||
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
describe(useCached, () => {
|
||||
it('default comparator', async () => {
|
||||
const externalValue = ref(0);
|
||||
const cachedValue = useCached(externalValue);
|
||||
|
||||
expect(cachedValue.value).toBe(0);
|
||||
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
});
|
||||
|
||||
it('custom array comparator', async () => {
|
||||
const externalValue = ref([1]);
|
||||
const initialValue = externalValue.value;
|
||||
|
||||
const cachedValue = useCached(externalValue, arrayEquals);
|
||||
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = initialValue;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = [1];
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = [2];
|
||||
await nextTick();
|
||||
expect(cachedValue.value).not.toEqual(initialValue);
|
||||
expect(cachedValue.value).toEqual([2]);
|
||||
});
|
||||
|
||||
it('getter source', async () => {
|
||||
const externalValue = reactive({ value: 0 });
|
||||
const cachedValue = useCached(() => externalValue.value);
|
||||
|
||||
expect(cachedValue.value).toBe(0);
|
||||
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
});
|
||||
});
|
||||
39
vue/toolkit/src/composables/reactivity/useCached/index.ts
Normal file
39
vue/toolkit/src/composables/reactivity/useCached/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref, watch, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
|
||||
export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
|
||||
/**
|
||||
* @name useCached
|
||||
* @category Reactivity
|
||||
* @description Caches the value of an external ref and updates it only when the value changes
|
||||
*
|
||||
* @param {Ref<T>} externalValue Ref to cache
|
||||
* @param {Comparator<T>} comparator Comparator function to compare the values
|
||||
* @param {WatchOptions} watchOptions Watch options
|
||||
* @returns {Ref<T>} Cached ref
|
||||
*
|
||||
* @example
|
||||
* const externalValue = ref(0);
|
||||
* const cachedValue = useCached(externalValue);
|
||||
*
|
||||
* @example
|
||||
* const externalValue = ref(0);
|
||||
* const cachedValue = useCached(externalValue, (a, b) => a === b, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCached<Value = unknown>(
|
||||
externalValue: MaybeRefOrGetter<Value>,
|
||||
comparator: Comparator<Value> = (a, b) => a === b,
|
||||
watchOptions?: WatchOptions,
|
||||
): Ref<Value> {
|
||||
const cached = ref(toValue(externalValue)) as Ref<Value>;
|
||||
|
||||
watch(() => toValue(externalValue), (value) => {
|
||||
if (!comparator(value, cached.value))
|
||||
cached.value = value;
|
||||
}, watchOptions);
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useLastChanged } from '.';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
|
||||
describe(useLastChanged, () => {
|
||||
it('initialize with null if no initialValue is provided', () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
expect(lastChanged.value).toBeNull();
|
||||
});
|
||||
|
||||
it('initialize with the provided initialValue', () => {
|
||||
const source = ref(0);
|
||||
const initialValue = 123456789;
|
||||
const lastChanged = useLastChanged(source, { initialValue });
|
||||
|
||||
expect(lastChanged.value).toBe(initialValue);
|
||||
});
|
||||
|
||||
it('update the timestamp when the source changes', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).not.toBe(initialTimestamp);
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('update the timestamp immediately if immediate option is true', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source, { immediate: true });
|
||||
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('not update the timestamp if the source does not change', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { WatchSource, WatchOptions, Ref } from 'vue';
|
||||
|
||||
export interface UseLastChangedOptions<
|
||||
Immediate extends boolean,
|
||||
InitialValue extends number | null | undefined = undefined,
|
||||
> extends WatchOptions<Immediate> {
|
||||
initialValue?: InitialValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useLastChanged
|
||||
* @category Reactivity
|
||||
* @description Records the last time a value changed
|
||||
*
|
||||
* @param {WatchSource} source The value to track
|
||||
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
|
||||
* @returns {Ref<number | null>} The timestamp of the last change
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value);
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>;
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
|
||||
const lastChanged = ref<number | null>(options.initialValue ?? null);
|
||||
|
||||
watch(source, () => {
|
||||
lastChanged.value = timestamp();
|
||||
}, options);
|
||||
|
||||
return lastChanged;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useSyncRefs } from '.';
|
||||
|
||||
describe(useSyncRefs, () => {
|
||||
it('sync the value of a source ref with multiple target refs', () => {
|
||||
const source = ref(0);
|
||||
const target1 = ref(0);
|
||||
const target2 = ref(0);
|
||||
useSyncRefs(source, [target1, target2]);
|
||||
|
||||
source.value = 10;
|
||||
|
||||
expect(target1.value).toBe(10);
|
||||
expect(target2.value).toBe(10);
|
||||
});
|
||||
|
||||
it('sync the value of a source ref with a single target ref', () => {
|
||||
const source = ref(0);
|
||||
const target = ref(0);
|
||||
useSyncRefs(source, target);
|
||||
|
||||
source.value = 20;
|
||||
|
||||
expect(target.value).toBe(20);
|
||||
});
|
||||
|
||||
it('stop watching when the stop handle is called', () => {
|
||||
const source = ref(0);
|
||||
const target = ref(0);
|
||||
const stop = useSyncRefs(source, target);
|
||||
|
||||
source.value = 30;
|
||||
stop();
|
||||
source.value = 40;
|
||||
|
||||
expect(target.value).toBe(30);
|
||||
});
|
||||
});
|
||||
47
vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts
Normal file
47
vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { watch } from 'vue';
|
||||
import type { Ref, WatchOptions, WatchSource } from 'vue';
|
||||
import { isArray } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* @name useSyncRefs
|
||||
* @category Reactivity
|
||||
* @description Syncs the value of a source ref with multiple target refs
|
||||
*
|
||||
* @param {WatchSource<T>} source Source ref to sync
|
||||
* @param {Ref<T> | Ref<T>[]} targets Target refs to sync
|
||||
* @param {WatchOptions} watchOptions Watch options
|
||||
* @returns {WatchStopHandle} Watch stop handle
|
||||
*
|
||||
* @example
|
||||
* const source = ref(0);
|
||||
* const target1 = ref(0);
|
||||
* const target2 = ref(0);
|
||||
* useSyncRefs(source, [target1, target2]);
|
||||
*
|
||||
* @example
|
||||
* const source = ref(0);
|
||||
* const target1 = ref(0);
|
||||
* useSyncRefs(source, target1, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useSyncRefs<T = unknown>(
|
||||
source: WatchSource<T>,
|
||||
targets: Ref<T> | Ref<T>[],
|
||||
watchOptions: WatchOptions = {},
|
||||
) {
|
||||
const {
|
||||
flush = 'sync',
|
||||
deep = false,
|
||||
immediate = true,
|
||||
} = watchOptions;
|
||||
|
||||
if (!isArray(targets))
|
||||
targets = [targets];
|
||||
|
||||
return watch(
|
||||
source,
|
||||
value => targets.forEach((target) => { target.value = value; }),
|
||||
{ flush, deep, immediate },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user