Skip to content

Commit 3c2738f

Browse files
committed
standardize _hyperscripts storage mechanism to match htmx
1 parent f5b05aa commit 3c2738f

3 files changed

Lines changed: 108 additions & 54 deletions

File tree

src/core/runtime/runtime.js

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ export class Runtime {
4444
#tokenizer;
4545
#globalScope;
4646
#scriptAttrs = null;
47-
#hyperscriptFeaturesMap = new WeakMap;
48-
#internalDataMap = new WeakMap;
4947

5048
constructor(globalScope, kernel, tokenizer) {
5149
this.#globalScope = globalScope;
@@ -212,13 +210,11 @@ export class Runtime {
212210
}
213211

214212
getHyperscriptFeatures(elt) {
215-
var hyperscriptFeatures = this.#hyperscriptFeaturesMap.get(elt);
216-
if (typeof hyperscriptFeatures === 'undefined') {
217-
if (elt) {
218-
this.#hyperscriptFeaturesMap.set(elt, hyperscriptFeatures = {});
219-
}
213+
var data = this.getInternalData(elt);
214+
if (!data.features) {
215+
data.features = {};
220216
}
221-
return hyperscriptFeatures;
217+
return data.features;
222218
}
223219

224220
addFeatures(owner, ctx) {
@@ -318,11 +314,10 @@ export class Runtime {
318314
}
319315

320316
getInternalData(elt) {
321-
var internalData = this.#internalDataMap.get(elt);
322-
if (typeof internalData === 'undefined') {
323-
this.#internalDataMap.set(elt, internalData = {});
317+
if (!elt._hyperscript) {
318+
elt._hyperscript = {};
324319
}
325-
return internalData;
320+
return elt._hyperscript;
326321
}
327322

328323
#getElementScope(context) {
@@ -585,39 +580,48 @@ export class Runtime {
585580
.join(", ");
586581
}
587582

583+
#hashScript(str) {
584+
var hash = 5381;
585+
for (var i = 0; i < str.length; i++) {
586+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
587+
}
588+
return hash;
589+
}
590+
588591
#initElement(elt, target) {
589592
if (elt.closest && elt.closest(config.disableSelector)) {
590593
return;
591594
}
592595
var internalData = this.getInternalData(elt);
593-
if (!internalData.initialized) {
594-
var src = this.#getScript(elt);
595-
if (src) {
596-
try {
597-
internalData.initialized = true;
598-
internalData.script = src;
599-
var tokens = this.#tokenizer.tokenize(src);
600-
var hyperScript = this.#kernel.parseHyperScript(tokens);
601-
if (!hyperScript) return;
602-
hyperScript.apply(target || elt, elt, null, this);
603-
setTimeout(() => {
604-
this.triggerEvent(target || elt, "load", {
605-
hyperscript: true,
606-
});
607-
}, 1);
608-
} catch (e) {
609-
this.triggerEvent(elt, "exception", {
610-
error: e,
611-
});
612-
console.error(
613-
"hyperscript errors were found on the following element:",
614-
elt,
615-
"\n\n",
616-
e.message,
617-
e.stack
618-
);
619-
}
620-
}
596+
var src = this.#getScript(elt);
597+
if (!src) return;
598+
var hash = this.#hashScript(src);
599+
if (internalData.initialized) {
600+
if (internalData.scriptHash === hash) return;
601+
}
602+
internalData.initialized = true;
603+
internalData.scriptHash = hash;
604+
try {
605+
var tokens = this.#tokenizer.tokenize(src);
606+
var hyperScript = this.#kernel.parseHyperScript(tokens);
607+
if (!hyperScript) return;
608+
hyperScript.apply(target || elt, elt, null, this);
609+
setTimeout(() => {
610+
this.triggerEvent(target || elt, "load", {
611+
hyperscript: true,
612+
});
613+
}, 1);
614+
} catch (e) {
615+
this.triggerEvent(elt, "exception", {
616+
error: e,
617+
});
618+
console.error(
619+
"hyperscript errors were found on the following element:",
620+
elt,
621+
"\n\n",
622+
e.message,
623+
e.stack
624+
);
621625
}
622626
}
623627

src/parsetree/features/on.js

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export class OnFeature extends Feature {
9494
} else {
9595
targets = [elt];
9696
}
97+
// Per-element event state (moved off parsed eventSpec)
98+
var internalData = runtime.getInternalData(elt);
99+
if (!internalData.eventState) internalData.eventState = new Map();
100+
if (!internalData.eventState.has(eventSpec)) {
101+
internalData.eventState.set(eventSpec, { execCount: 0, debounced: undefined, lastExec: undefined });
102+
}
103+
var eventState = internalData.eventState.get(eventSpec);
104+
97105
runtime.implicitLoop(targets, function (target) {
98106
// OK NO PROMISE
99107

@@ -194,30 +202,30 @@ export class OnFeature extends Feature {
194202
}
195203

196204
// verify counts
197-
eventSpec.execCount++;
205+
eventState.execCount++;
198206
if (eventSpec.startCount) {
199207
if (eventSpec.endCount) {
200208
if (
201-
eventSpec.execCount < eventSpec.startCount ||
202-
eventSpec.execCount > eventSpec.endCount
209+
eventState.execCount < eventSpec.startCount ||
210+
eventState.execCount > eventSpec.endCount
203211
) {
204212
return;
205213
}
206214
} else if (eventSpec.unbounded) {
207-
if (eventSpec.execCount < eventSpec.startCount) {
215+
if (eventState.execCount < eventSpec.startCount) {
208216
return;
209217
}
210-
} else if (eventSpec.execCount !== eventSpec.startCount) {
218+
} else if (eventState.execCount !== eventSpec.startCount) {
211219
return;
212220
}
213221
}
214222

215223
//debounce
216224
if (eventSpec.debounceTime) {
217-
if (eventSpec.debounced) {
218-
clearTimeout(eventSpec.debounced);
225+
if (eventState.debounced) {
226+
clearTimeout(eventState.debounced);
219227
}
220-
eventSpec.debounced = setTimeout(function () {
228+
eventState.debounced = setTimeout(function () {
221229
onFeature.execute(ctx);
222230
}, eventSpec.debounceTime);
223231
return;
@@ -226,12 +234,12 @@ export class OnFeature extends Feature {
226234
// throttle
227235
if (eventSpec.throttleTime) {
228236
if (
229-
eventSpec.lastExec &&
230-
Date.now() < (eventSpec.lastExec + eventSpec.throttleTime)
237+
eventState.lastExec &&
238+
Date.now() < (eventState.lastExec + eventSpec.throttleTime)
231239
) {
232240
return;
233241
} else {
234-
eventSpec.lastExec = Date.now();
242+
eventState.lastExec = Date.now();
235243
}
236244
}
237245

@@ -380,7 +388,6 @@ export class OnFeature extends Feature {
380388
}
381389

382390
events.push({
383-
execCount: 0,
384391
every: every,
385392
on: eventName,
386393
args: args,
@@ -395,8 +402,6 @@ export class OnFeature extends Feature {
395402
throttleTime: throttleTime,
396403
mutationSpec: mutationSpec,
397404
intersectionSpec: intersectionSpec,
398-
debounced: undefined,
399-
lastExec: undefined,
400405
});
401406
} while (parser.matchToken("or"));
402407

test/core/bootstrap.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,49 @@ test.describe("_hyperscript bootstrapping", () => {
110110
await find('div').dispatchEvent('click');
111111
expect(await evaluate(() => window.calledWith)).toBe("foo");
112112
});
113+
114+
test("stores state on elt._hyperscript", async ({html, find, evaluate}) => {
115+
await html("<div _='on click add .foo'></div>");
116+
var state = await evaluate(() => {
117+
var div = document.querySelector('#work-area div');
118+
return {
119+
hasProperty: '_hyperscript' in div,
120+
initialized: div._hyperscript?.initialized,
121+
hasScriptHash: typeof div._hyperscript?.scriptHash === 'number',
122+
};
123+
});
124+
expect(state.hasProperty).toBe(true);
125+
expect(state.initialized).toBe(true);
126+
expect(state.hasScriptHash).toBe(true);
127+
});
128+
129+
test("skips reinitialization if script unchanged", async ({html, find, evaluate}) => {
130+
await html("<div _='on click add .foo'></div>");
131+
// Process again — should be a no-op
132+
var clickCount = await evaluate(() => {
133+
var div = document.querySelector('#work-area div');
134+
var count = 0;
135+
div.addEventListener('click', () => count++);
136+
_hyperscript.processNode(div);
137+
div.click();
138+
return count;
139+
});
140+
// Only 1 click handler should fire (the original), not 2
141+
expect(clickCount).toBe(1);
142+
});
143+
144+
test("reinitializes if script attribute changes", async ({html, find, evaluate}) => {
145+
await html("<div _='on click add .foo'></div>");
146+
await find('div').dispatchEvent('click');
147+
await expect(find('div')).toHaveClass(/foo/);
148+
149+
// Change the script and reprocess (simulates morph swap)
150+
await evaluate(() => {
151+
var div = document.querySelector('#work-area div');
152+
div.setAttribute('_', 'on click add .bar');
153+
_hyperscript.processNode(div);
154+
});
155+
await find('div').dispatchEvent('click');
156+
await expect(find('div')).toHaveClass(/bar/);
157+
});
113158
});

0 commit comments

Comments
 (0)