Skip to content

Commit ba76b4f

Browse files
committed
add cleanup mechanism like htmx
1 parent 3c2738f commit ba76b4f

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

src/_hyperscript.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const _hyperscript = Object.assign(
123123
parse: (src) => kernel.parse(tokenizer, src),
124124
process: (elt) => runtime.processNode(elt),
125125
processNode: (elt) => runtime.processNode(elt), // deprecated alias
126+
cleanup: (elt) => runtime.cleanup(elt),
126127
version: "0.9.90",
127128
}
128129
);

src/core/runtime/runtime.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,41 @@ export class Runtime {
588588
return hash;
589589
}
590590

591+
cleanup(elt) {
592+
if (!elt._hyperscript) return;
593+
var data = elt._hyperscript;
594+
595+
// Remove registered event listeners
596+
if (data.listeners) {
597+
for (var info of data.listeners) {
598+
info.target.removeEventListener(info.event, info.handler);
599+
}
600+
}
601+
602+
// Disconnect observers
603+
if (data.observers) {
604+
for (var observer of data.observers) {
605+
observer.disconnect();
606+
}
607+
}
608+
609+
// Clear debounce/throttle timers
610+
if (data.eventState) {
611+
for (var state of data.eventState.values()) {
612+
if (state.debounced) clearTimeout(state.debounced);
613+
}
614+
}
615+
616+
// Recursively clean children
617+
if (elt.querySelectorAll) {
618+
for (var child of elt.querySelectorAll('[_], [data-hs], [hyperscript]')) {
619+
this.cleanup(child);
620+
}
621+
}
622+
623+
delete elt._hyperscript;
624+
}
625+
591626
#initElement(elt, target) {
592627
if (elt.closest && elt.closest(config.disableSelector)) {
593628
return;
@@ -598,6 +633,9 @@ export class Runtime {
598633
var hash = this.#hashScript(src);
599634
if (internalData.initialized) {
600635
if (internalData.scriptHash === hash) return;
636+
// Script changed (e.g. morph swap) — clean up and reinitialize
637+
this.cleanup(elt);
638+
internalData = this.getInternalData(elt);
601639
}
602640
internalData.initialized = true;
603641
internalData.scriptHash = hash;

src/parsetree/features/on.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ export class OnFeature extends Feature {
111111
return;
112112
}
113113

114+
var eltData = runtime.getInternalData(elt);
115+
if (!eltData.listeners) eltData.listeners = [];
116+
if (!eltData.observers) eltData.observers = [];
117+
114118
if (eventSpec.mutationSpec) {
115119
eventName = "hyperscript:mutation";
116120
const observer = new MutationObserver(function (mutationList, observer) {
@@ -122,6 +126,7 @@ export class OnFeature extends Feature {
122126
}
123127
});
124128
observer.observe(target, eventSpec.mutationSpec);
129+
eltData.observers.push(observer);
125130
}
126131

127132
if (eventSpec.intersectionSpec) {
@@ -137,10 +142,12 @@ export class OnFeature extends Feature {
137142
}
138143
}, eventSpec.intersectionSpec);
139144
observer.observe(target);
145+
eltData.observers.push(observer);
140146
}
141147

142148
var addEventListener = target.addEventListener || target.on;
143-
addEventListener.call(target, eventName, function listener(evt) {
149+
var handler;
150+
addEventListener.call(target, eventName, handler = function listener(evt) {
144151
// OK NO PROMISE
145152
if (typeof Node !== 'undefined' && elt instanceof Node && target !== elt && !elt.isConnected) {
146153
target.removeEventListener(eventName, listener);
@@ -246,6 +253,7 @@ export class OnFeature extends Feature {
246253
// apply execute
247254
onFeature.execute(ctx);
248255
});
256+
eltData.listeners.push({ target: target, event: eventName, handler: handler });
249257
});
250258
}
251259
}

test/core/bootstrap.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,62 @@ test.describe("_hyperscript bootstrapping", () => {
155155
await find('div').dispatchEvent('click');
156156
await expect(find('div')).toHaveClass(/bar/);
157157
});
158+
159+
test("cleanup removes event listeners on the element", async ({html, find, evaluate}) => {
160+
await html("<div _='on click add .foo'></div>");
161+
await find('div').dispatchEvent('click');
162+
await expect(find('div')).toHaveClass(/foo/);
163+
164+
// Cleanup and verify listener is gone
165+
await evaluate(() => {
166+
var div = document.querySelector('#work-area div');
167+
_hyperscript.cleanup(div);
168+
div.classList.remove('foo');
169+
div.click();
170+
});
171+
await expect(find('div')).not.toHaveClass(/foo/);
172+
});
173+
174+
test("cleanup removes cross-element event listeners", async ({html, find, evaluate}) => {
175+
await html("<div id='source'></div><div id='target' _='on click from #source add .foo'></div>");
176+
177+
// Verify the cross-element listener works
178+
await find('#source').dispatchEvent('click');
179+
await expect(find('#target')).toHaveClass(/foo/);
180+
181+
// Cleanup target and verify listener on source is removed
182+
var listenerRemoved = await evaluate(() => {
183+
var source = document.getElementById('source');
184+
var target = document.getElementById('target');
185+
_hyperscript.cleanup(target);
186+
target.classList.remove('foo');
187+
source.click();
188+
// If cleanup worked, target should NOT get .foo again
189+
return !target.classList.contains('foo');
190+
});
191+
expect(listenerRemoved).toBe(true);
192+
});
193+
194+
test("cleanup tracks listeners in elt._hyperscript", async ({html, find, evaluate}) => {
195+
await html("<div _='on click add .foo'></div>");
196+
var info = await evaluate(() => {
197+
var div = document.querySelector('#work-area div');
198+
return {
199+
hasListeners: Array.isArray(div._hyperscript.listeners),
200+
listenerCount: div._hyperscript.listeners.length,
201+
};
202+
});
203+
expect(info.hasListeners).toBe(true);
204+
expect(info.listenerCount).toBeGreaterThan(0);
205+
});
206+
207+
test("cleanup clears elt._hyperscript", async ({html, find, evaluate}) => {
208+
await html("<div _='on click add .foo'></div>");
209+
var hasState = await evaluate(() => {
210+
var div = document.querySelector('#work-area div');
211+
_hyperscript.cleanup(div);
212+
return '_hyperscript' in div;
213+
});
214+
expect(hasState).toBe(false);
215+
});
158216
});

0 commit comments

Comments
 (0)