From db8e381d987f163a1e21cd5cc36814b2848d450a Mon Sep 17 00:00:00 2001 From: Sean <22557931+sean0407@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:37:37 +0800 Subject: [PATCH] fix: preserve modals opened during close cleanup --- .github/workflows/php.yml | 10 +++++- package.json | 3 +- resources/js/modal.js | 16 ++++++++-- tests/js/modal.test.cjs | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 tests/js/modal.test.cjs diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b2c53a8..2efd0e7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -21,6 +21,14 @@ jobs: - name: Checkout uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run JavaScript tests + run: npm run test:js + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -35,4 +43,4 @@ jobs: composer update --prefer-dist --no-interaction --no-suggest - name: Run test suite - run: composer run-script test \ No newline at end of file + run: composer run-script test diff --git a/package.json b/package.json index be069ea..475915f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "watch-poll": "mix watch -- --watch-options-poll=1000", "hot": "mix watch --hot", "prod": "npm run production", - "production": "mix --production" + "production": "mix --production", + "test:js": "node --test tests/js/*.test.cjs" }, "devDependencies": { "autoprefixer": "^10.2.5", diff --git a/resources/js/modal.js b/resources/js/modal.js index ef275b1..6b23269 100644 --- a/resources/js/modal.js +++ b/resources/js/modal.js @@ -6,6 +6,7 @@ window.LivewireUIModal = () => { componentHistory: [], modalWidth: null , listeners: [], + resetStateTimeout: null, getActiveComponentModalAttribute(key) { if (this.$wire.get('components')[this.activeComponent] !== undefined) { return this.$wire.get('components')[this.activeComponent]['modalAttributes'][key]; @@ -148,13 +149,22 @@ window.LivewireUIModal = () => { this.show = show; if (show) { + if (this.resetStateTimeout !== null) { + clearTimeout(this.resetStateTimeout); + this.resetStateTimeout = null; + } + document.body.classList.add('overflow-y-hidden'); } else { document.body.classList.remove('overflow-y-hidden'); - setTimeout(() => { - this.activeComponent = false; - this.$wire.resetState(); + this.resetStateTimeout = setTimeout(() => { + this.resetStateTimeout = null; + + if (this.show === false) { + this.activeComponent = false; + this.$wire.resetState(); + } }, 300); } }, diff --git a/tests/js/modal.test.cjs b/tests/js/modal.test.cjs new file mode 100644 index 0000000..22bdc56 --- /dev/null +++ b/tests/js/modal.test.cjs @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); + +const modalSource = fs.readFileSync( + path.resolve(__dirname, '../../resources/js/modal.js'), + 'utf8' +); + +function createModal() { + let nextTimerId = 0; + const timers = new Map(); + const cancelledTimers = new Set(); + const context = { + window: {}, + document: { body: { classList: { add() {}, remove() {} } } }, + setTimeout(callback, delay) { + const id = ++nextTimerId; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeout(id) { + cancelledTimers.add(id); + }, + }; + + vm.runInNewContext(modalSource, context, { timeout: 1000 }); + + return { + modal: context.window.LivewireUIModal(), + timers, + cancelledTimers, + }; +} + +test('closing an old modal does not reset a modal opened before cleanup finishes', () => { + const { modal, timers, cancelledTimers } = createModal(); + let resetCount = 0; + modal.$wire = { resetState() { resetCount++; } }; + + modal.setShowPropertyTo(false); + const closeTimerId = modal.resetStateTimeout; + modal.setShowPropertyTo(true); + modal.activeComponent = 'new-modal'; + + assert.equal(timers.get(closeTimerId).delay, 300); + assert.equal(cancelledTimers.has(closeTimerId), true); + assert.equal(resetCount, 0); + assert.equal(modal.activeComponent, 'new-modal'); +}); + +test('a closed modal still resets after the close cleanup delay', () => { + const { modal, timers } = createModal(); + let resetCount = 0; + modal.$wire = { resetState() { resetCount++; } }; + + modal.setShowPropertyTo(false); + const closeTimerId = modal.resetStateTimeout; + timers.get(closeTimerId).callback(); + + assert.equal(resetCount, 1); + assert.equal(modal.activeComponent, false); + assert.equal(modal.resetStateTimeout, null); +});