From 8c5252986e547a43937c1cbc5b8ef9cfa615811e Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 23 Feb 2025 00:44:14 +0700 Subject: [PATCH 1/7] feat(packages/stdlib): add SyncMutex --- packages/stdlib/src/index.ts | 1 + packages/stdlib/src/sync/index.ts | 1 + packages/stdlib/src/sync/mutex/index.test.ts | 45 ++++++++++++++++++++ packages/stdlib/src/sync/mutex/index.ts | 29 +++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 packages/stdlib/src/sync/index.ts create mode 100644 packages/stdlib/src/sync/mutex/index.test.ts create mode 100644 packages/stdlib/src/sync/mutex/index.ts diff --git a/packages/stdlib/src/index.ts b/packages/stdlib/src/index.ts index 9e936f1..62ebea0 100644 --- a/packages/stdlib/src/index.ts +++ b/packages/stdlib/src/index.ts @@ -5,6 +5,7 @@ export * from './math'; export * from './objects'; export * from './patterns'; export * from './structs'; +export * from './sync'; export * from './text'; export * from './types'; export * from './utils' diff --git a/packages/stdlib/src/sync/index.ts b/packages/stdlib/src/sync/index.ts new file mode 100644 index 0000000..b1ad937 --- /dev/null +++ b/packages/stdlib/src/sync/index.ts @@ -0,0 +1 @@ +export * from './mutex'; diff --git a/packages/stdlib/src/sync/mutex/index.test.ts b/packages/stdlib/src/sync/mutex/index.test.ts new file mode 100644 index 0000000..c50ae0b --- /dev/null +++ b/packages/stdlib/src/sync/mutex/index.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SyncMutex } from '.'; + +describe('SyncMutex', () => { + let mutex: SyncMutex; + + beforeEach(() => { + mutex = new SyncMutex(); + }); + + it('unlocked by default', () => { + expect(mutex.isLocked).toBe(false); + }); + + it('lock the mutex', () => { + mutex.lock(); + expect(mutex.isLocked).toBe(true); + }); + + it('remain locked when locked multiple times', () => { + mutex.lock(); + mutex.lock(); + expect(mutex.isLocked).toBe(true); + }); + + it('unlock a locked mutex', () => { + mutex.lock(); + mutex.unlock(); + expect(mutex.isLocked).toBe(false); + }); + + it('remain unlocked when unlocked multiple times', () => { + mutex.unlock(); + mutex.unlock(); + expect(mutex.isLocked).toBe(false); + }); + + it('reflect the current lock state', () => { + expect(mutex.isLocked).toBe(false); + mutex.lock(); + expect(mutex.isLocked).toBe(true); + mutex.unlock(); + expect(mutex.isLocked).toBe(false); + }); +}); diff --git a/packages/stdlib/src/sync/mutex/index.ts b/packages/stdlib/src/sync/mutex/index.ts new file mode 100644 index 0000000..acb1610 --- /dev/null +++ b/packages/stdlib/src/sync/mutex/index.ts @@ -0,0 +1,29 @@ +/** + * @name SyncMutex + * @category Utils + * @description A simple synchronous mutex to provide more readable locking and unlocking of code blocks + * + * @example + * const mutex = new SyncMutex(); + * + * mutex.lock(); + * + * mutex.unlock(); + * + * @since 0.0.5 + */ +export class SyncMutex { + private state: boolean = false; + + public get isLocked() { + return this.state; + } + + public lock() { + this.state = true; + } + + public unlock() { + this.state = false; + } +} From a07ac35db952028f7ea155cce052327015147471 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 23 Feb 2025 00:44:58 +0700 Subject: [PATCH 2/7] fix(packages/vue): add mutex for useRenderCount to avoid infinite rerender --- .../src/composables/useRenderCount/index.test.ts | 6 +++--- .../vue/src/composables/useRenderCount/index.ts | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/vue/src/composables/useRenderCount/index.test.ts b/packages/vue/src/composables/useRenderCount/index.test.ts index 8fe517f..93b8ace 100644 --- a/packages/vue/src/composables/useRenderCount/index.test.ts +++ b/packages/vue/src/composables/useRenderCount/index.test.ts @@ -32,7 +32,7 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a render - expect(component.vm.count).toBe(2); + expect(component.vm.count).toBe(1); expect(component.text()).toBe('1'); component.vm.visibleCount++; @@ -40,7 +40,7 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a single render for both updates - expect(component.vm.count).toBe(3); + expect(component.vm.count).toBe(2); expect(component.text()).toBe('3'); }); @@ -70,6 +70,6 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a single render for both updates - expect(count.value).toBe(2); + expect(count.value).toBe(1); }); }); \ No newline at end of file diff --git a/packages/vue/src/composables/useRenderCount/index.ts b/packages/vue/src/composables/useRenderCount/index.ts index d40737b..1037271 100644 --- a/packages/vue/src/composables/useRenderCount/index.ts +++ b/packages/vue/src/composables/useRenderCount/index.ts @@ -1,6 +1,7 @@ import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue'; import { useCounter } from '../useCounter'; import { getLifeCycleTarger } from '../..'; +import { SyncMutex } from '@robonen/stdlib'; /** * @name useRenderCount @@ -19,11 +20,22 @@ import { getLifeCycleTarger } from '../..'; * @since 0.0.1 */ export function useRenderCount(instance?: ComponentInternalInstance) { + const mutex = new SyncMutex(); const { count, increment } = useCounter(0); const target = getLifeCycleTarger(instance); - onMounted(increment, target); - onUpdated(increment, target); + const incrementEffect = () => { + if (mutex.isLocked) { + mutex.unlock(); + return; + } + + mutex.lock(); + increment(); + }; + + onMounted(incrementEffect, target); + onUpdated(incrementEffect, target); return readonly(count); } \ No newline at end of file From 3a2837c1a1b4f6dbdc0f8fb2286b8130c6c8a2d0 Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 9 May 2025 13:09:03 +0700 Subject: [PATCH 3/7] fix(packages/vue): revert to old version useRenderCount --- .../src/composables/useRenderCount/index.test.ts | 6 +++--- .../vue/src/composables/useRenderCount/index.ts | 16 ++-------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/vue/src/composables/useRenderCount/index.test.ts b/packages/vue/src/composables/useRenderCount/index.test.ts index 93b8ace..8fe517f 100644 --- a/packages/vue/src/composables/useRenderCount/index.test.ts +++ b/packages/vue/src/composables/useRenderCount/index.test.ts @@ -32,7 +32,7 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a render - expect(component.vm.count).toBe(1); + expect(component.vm.count).toBe(2); expect(component.text()).toBe('1'); component.vm.visibleCount++; @@ -40,7 +40,7 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a single render for both updates - expect(component.vm.count).toBe(2); + expect(component.vm.count).toBe(3); expect(component.text()).toBe('3'); }); @@ -70,6 +70,6 @@ describe('useRenderCount', () => { await nextTick(); // Will trigger a single render for both updates - expect(count.value).toBe(1); + expect(count.value).toBe(2); }); }); \ No newline at end of file diff --git a/packages/vue/src/composables/useRenderCount/index.ts b/packages/vue/src/composables/useRenderCount/index.ts index 1037271..d40737b 100644 --- a/packages/vue/src/composables/useRenderCount/index.ts +++ b/packages/vue/src/composables/useRenderCount/index.ts @@ -1,7 +1,6 @@ import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue'; import { useCounter } from '../useCounter'; import { getLifeCycleTarger } from '../..'; -import { SyncMutex } from '@robonen/stdlib'; /** * @name useRenderCount @@ -20,22 +19,11 @@ import { SyncMutex } from '@robonen/stdlib'; * @since 0.0.1 */ export function useRenderCount(instance?: ComponentInternalInstance) { - const mutex = new SyncMutex(); const { count, increment } = useCounter(0); const target = getLifeCycleTarger(instance); - const incrementEffect = () => { - if (mutex.isLocked) { - mutex.unlock(); - return; - } - - mutex.lock(); - increment(); - }; - - onMounted(incrementEffect, target); - onUpdated(incrementEffect, target); + onMounted(increment, target); + onUpdated(increment, target); return readonly(count); } \ No newline at end of file From 8d6f08c332f3b34d2e7956513fd57847ce85f868 Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 9 May 2025 13:11:20 +0700 Subject: [PATCH 4/7] fix(packages/vue): set render duration ref only after mounted and updated --- packages/vue/src/composables/useRenderInfo/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/composables/useRenderInfo/index.ts b/packages/vue/src/composables/useRenderInfo/index.ts index e7518c8..d480ceb 100644 --- a/packages/vue/src/composables/useRenderInfo/index.ts +++ b/packages/vue/src/composables/useRenderInfo/index.ts @@ -22,9 +22,13 @@ import { getLifeCycleTarger } from '../..'; export function useRenderInfo(instance?: ComponentInternalInstance) { const target = getLifeCycleTarger(instance); const duration = ref(0); + let startTime = 0; - const startMark = () => duration.value = performance.now(); - const endMark = () => duration.value = Math.max(performance.now() - duration.value, 0); + const startMark = () => startTime = performance.now(); + const endMark = () => { + duration.value = Math.max(performance.now() - startTime, 0); + startTime = 0; + }; onBeforeMount(startMark, target); onMounted(endMark, target); From 3994f349f4412e66f7ed6825fd04f8aad5c19f7d Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 9 May 2025 13:12:07 +0700 Subject: [PATCH 5/7] feat(packages/stdlib): add execute method for SyncMutex --- packages/stdlib/src/sync/mutex/index.test.ts | 51 +++++++++++++++++++- packages/stdlib/src/sync/mutex/index.ts | 20 +++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/stdlib/src/sync/mutex/index.test.ts b/packages/stdlib/src/sync/mutex/index.test.ts index c50ae0b..3b97fc0 100644 --- a/packages/stdlib/src/sync/mutex/index.test.ts +++ b/packages/stdlib/src/sync/mutex/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SyncMutex } from '.'; describe('SyncMutex', () => { @@ -14,24 +14,28 @@ describe('SyncMutex', () => { it('lock the mutex', () => { mutex.lock(); + expect(mutex.isLocked).toBe(true); }); it('remain locked when locked multiple times', () => { mutex.lock(); mutex.lock(); + expect(mutex.isLocked).toBe(true); }); it('unlock a locked mutex', () => { mutex.lock(); mutex.unlock(); + expect(mutex.isLocked).toBe(false); }); it('remain unlocked when unlocked multiple times', () => { mutex.unlock(); mutex.unlock(); + expect(mutex.isLocked).toBe(false); }); @@ -42,4 +46,49 @@ describe('SyncMutex', () => { mutex.unlock(); expect(mutex.isLocked).toBe(false); }); + + it('execute a callback when unlocked', async () => { + const callback = vi.fn(() => 'done'); + const result = await mutex.execute(callback); + + expect(result).toBe('done'); + expect(callback).toHaveBeenCalled(); + }); + + it('execute a promise callback when unlocked', async () => { + const callback = vi.fn(() => Promise.resolve('done')); + const result = await mutex.execute(callback); + + expect(result).toBe('done'); + expect(callback).toHaveBeenCalled(); + }); + + it('execute concurrent callbacks only one at a time', async () => { + const callback = vi.fn(() => Promise.resolve('done')); + + const result = await Promise.all([ + mutex.execute(callback), + mutex.execute(callback), + mutex.execute(callback), + ]); + + expect(result).toEqual(['done', undefined, undefined]); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not execute a callback when locked', async () => { + const callback = vi.fn(() => 'done'); + mutex.lock(); + const result = await mutex.execute(callback); + + expect(result).toBeUndefined(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('unlocks after executing a callback', async () => { + const callback = vi.fn(() => 'done'); + await mutex.execute(callback); + + expect(mutex.isLocked).toBe(false); + }); }); diff --git a/packages/stdlib/src/sync/mutex/index.ts b/packages/stdlib/src/sync/mutex/index.ts index acb1610..991bc19 100644 --- a/packages/stdlib/src/sync/mutex/index.ts +++ b/packages/stdlib/src/sync/mutex/index.ts @@ -1,3 +1,5 @@ +import type { MaybePromise } from "../../types"; + /** * @name SyncMutex * @category Utils @@ -10,10 +12,15 @@ * * mutex.unlock(); * + * const result = await mutex.execute(() => { + * // do something + * return Promise.resolve('done'); + * }); + * * @since 0.0.5 */ export class SyncMutex { - private state: boolean = false; + private state = false; public get isLocked() { return this.state; @@ -26,4 +33,15 @@ export class SyncMutex { public unlock() { this.state = false; } + + public async execute(callback: () => T) { + if (this.isLocked) + return; + + this.lock(); + const result = await callback(); + this.unlock(); + + return result; + } } From 4ead7fb18cb15eccdddaf001529b255385929d07 Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 9 May 2025 13:12:37 +0700 Subject: [PATCH 6/7] chore: add npm-publish gh action --- .github/workflows/ci.yaml | 5 ++- .github/workflows/npm-publish.yaml | 68 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/npm-publish.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3dfd9c5..5e6acbf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,10 @@ name: CI on: - - pull_request + pull_request: + push: + branches: + - master env: NODE_VERSION: 22.x diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml new file mode 100644 index 0000000..8b8364a --- /dev/null +++ b/.github/workflows/npm-publish.yaml @@ -0,0 +1,68 @@ +name: Publish to NPM + +on: + push: + branches: + - main + +env: + NODE_VERSION: 22.x + +jobs: + check-and-publish: + name: Check version changes and publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm all:build + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v46 + with: + files: packages/*/package.json + + - name: Check for version changes and publish + if: steps.changed-files.outputs.any_changed == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + PACKAGE_DIR=$(dirname $file) + echo "Checking $PACKAGE_DIR for version changes..." + + # Get the latest published version from npm + PACKAGE_NAME=$(node -p "require('./$file').name") + CURRENT_VERSION=$(node -p "require('./$file').version") + + # Check if package exists on npm + NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0") + + # Compare versions + if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then + echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION" + echo "Publishing $PACKAGE_NAME@$CURRENT_VERSION" + cd $PACKAGE_DIR + npm publish --access public + cd - + else + echo "No version change detected for $PACKAGE_NAME" + fi + done From c7048be9fbb562704fa6b298eb9460b7450a9a6d Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 9 May 2025 13:18:12 +0700 Subject: [PATCH 7/7] chore(packages/vue): rename startTime to renderStartTime in useRenderInfo --- packages/vue/src/composables/useRenderInfo/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/composables/useRenderInfo/index.ts b/packages/vue/src/composables/useRenderInfo/index.ts index d480ceb..bbb4a1f 100644 --- a/packages/vue/src/composables/useRenderInfo/index.ts +++ b/packages/vue/src/composables/useRenderInfo/index.ts @@ -22,12 +22,12 @@ import { getLifeCycleTarger } from '../..'; export function useRenderInfo(instance?: ComponentInternalInstance) { const target = getLifeCycleTarger(instance); const duration = ref(0); - let startTime = 0; + let renderStartTime = 0; - const startMark = () => startTime = performance.now(); + const startMark = () => renderStartTime = performance.now(); const endMark = () => { - duration.value = Math.max(performance.now() - startTime, 0); - startTime = 0; + duration.value = Math.max(performance.now() - renderStartTime, 0); + renderStartTime = 0; }; onBeforeMount(startMark, target);