diff --git a/package.json b/package.json index 2953cfd..b32e63f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.7.6", + "version": "1.7.7", "description": "A reactive UI library for TypeScript and JavaScript. Connect state to HTML and SVG with direct DOM updates.", "author": "Ahmed Yasin Koculu", "license": "MIT", diff --git a/src/bind/IfBinder.ts b/src/bind/IfBinder.ts index 28e08b8..420d815 100644 --- a/src/bind/IfBinder.ts +++ b/src/bind/IfBinder.ts @@ -236,6 +236,12 @@ export class IfBinder { const capturedContext = parser.__capture() const refresh = (): void => { + // Removal detaches the region before deferred unbind stops this observer. + if ( + commentBegin.parentNode !== parent || + commentEnd.parentNode !== parent + ) + return parser.__scoped(capturedContext, () => { if (toBoolean(value()[0])) { if (!isIfMounted) { diff --git a/tests/bind/if-lifecycle.spec.ts b/tests/bind/if-lifecycle.spec.ts new file mode 100644 index 0000000..e85020c --- /dev/null +++ b/tests/bind/if-lifecycle.spec.ts @@ -0,0 +1,134 @@ +import { expect, test } from 'vitest' + +import { createApp, drainUnbind, html, observerCount, ref } from '../../src' + +test.each([false, true])( + 'a removed template ignores nested r-if updates (drain cleanup first: %s)', + async (drainFirst) => { + const root = document.createElement('div') + const visible = ref(true) + const warning = ref(false) + const app = createApp( + { visible, warning }, + { + element: root, + template: html``, + }, + ) + + try { + expect(root.querySelector('span')?.textContent).toBe('Results') + expect(observerCount(warning)).toBe(1) + visible(false) + if (drainFirst) await drainUnbind() + // Cached results can update nested state in the same turn as removal. + warning(true) + expect(root.querySelector('p')).toBeNull() + expect(root.querySelector('span')).toBeNull() + if (drainFirst) expect(observerCount(warning)).toBe(0) + + visible(true) + expect(root.querySelectorAll('p')).toHaveLength(1) + expect(root.querySelector('p')?.textContent).toBe('Older version') + await drainUnbind() + expect(observerCount(warning)).toBe(1) + warning(false) + expect(root.querySelector('p')).toBeNull() + warning(true) + expect(root.querySelectorAll('p')).toHaveLength(1) + } finally { + app.unbind() + await drainUnbind() + expect(observerCount(visible)).toBe(0) + expect(observerCount(warning)).toBe(0) + } + }, +) + +test.each([ + ['elseif', 'if'], + ['if', 'elseif'], + ['if', 'else'], + ['elseif', 'else'], + ['else', 'if'], + ['else', 'elseif'], +])( + 'a removed template ignores nested branch changes from %s to %s', + async (initialBranch, nextBranch) => { + const root = document.createElement('div') + const visible = ref(true) + const branch = ref(initialBranch) + const app = createApp( + { visible, branch }, + { + element: root, + template: html` + Outside`, + }, + ) + + try { + expect(root.querySelector('p')?.textContent).toBe(initialBranch) + visible(false) + branch(nextBranch) + expect(root.querySelector('p')).toBeNull() + expect(root.querySelector('strong')?.textContent).toBe('Outside') + + visible(true) + expect(root.querySelectorAll('p')).toHaveLength(1) + expect(root.querySelector('p')?.textContent).toBe(nextBranch) + await drainUnbind() + expect(root.querySelector('p')?.textContent).toBe(nextBranch) + } finally { + app.unbind() + await drainUnbind() + } + }, +) + +test('a removed template ignores an independent nested r-else-if observer', async () => { + const root = document.createElement('div') + const visible = ref(true) + const warning = ref(false) + const app = createApp( + { visible, warning }, + { + element: root, + template: html``, + }, + ) + + try { + expect(root.querySelector('p')?.textContent).toBe('Results') + visible(false) + warning(true) + expect(root.querySelector('p')).toBeNull() + warning(false) + expect(root.querySelector('p')).toBeNull() + warning(true) + await drainUnbind() + expect(observerCount(warning)).toBe(0) + + visible(true) + expect(root.querySelectorAll('p')).toHaveLength(1) + expect(root.querySelector('p')?.textContent).toBe('Older version') + warning(false) + expect(root.querySelectorAll('p')).toHaveLength(1) + expect(root.querySelector('p')?.textContent).toBe('Results') + } finally { + app.unbind() + await drainUnbind() + expect(observerCount(warning)).toBe(0) + } +})