Skip to content

Commit 2c74e64

Browse files
committed
make loop closures available in template-based scripts at runtime
1 parent b024761 commit 2c74e64

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

src/core/runtime/runtime.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,7 @@ export class Runtime {
849849
return;
850850
}
851851

852+
this.#resolveTemplateScopes(elt);
852853
hyperScript.apply(target || elt, elt, null, this);
853854
elt.setAttribute('data-hyperscript-powered', 'true');
854855
this.triggerEvent(elt, "hyperscript:after:init");
@@ -871,6 +872,44 @@ export class Runtime {
871872
}
872873
}
873874

875+
#resolveTemplateScopes(elt) {
876+
var root = elt.closest('[data-live-template], [dom-scope="isolated"]');
877+
if (!root || !root.__hs_scopes) return;
878+
879+
var matches = [];
880+
var node = elt;
881+
while (node && node !== root) {
882+
var prev = node.previousSibling;
883+
while (prev) {
884+
if (prev.nodeType === 8) {
885+
var text = prev.data;
886+
if (text.startsWith('hs-scope:')) {
887+
matches.push(text);
888+
break;
889+
}
890+
}
891+
prev = prev.previousSibling;
892+
}
893+
node = node.parentElement;
894+
}
895+
if (!matches.length) return;
896+
897+
var internalData = this.getInternalData(elt);
898+
if (!internalData.elementScope) internalData.elementScope = {};
899+
900+
for (var i = 0; i < matches.length; i++) {
901+
var parts = matches[i].split(':');
902+
var loopId = parts[1];
903+
var iter = parseInt(parts[2]);
904+
var scope = root.__hs_scopes[loopId];
905+
if (!scope) continue;
906+
internalData.elementScope[scope.identifier] = scope.source[iter];
907+
if (scope.indexIdentifier) {
908+
internalData.elementScope[scope.indexIdentifier] = iter;
909+
}
910+
}
911+
}
912+
874913
#beforeProcessHooks = [];
875914
#afterProcessHooks = [];
876915

src/ext/component.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export default function componentPlugin(_hyperscript) {
221221
var promise = new Promise(function(res, rej) { resolve = res; reject = rej; });
222222

223223
commandList.execute(ctx);
224+
this.__hs_scopes = ctx.meta.__ht_scopes || null;
224225

225226
// Sync case - command list completed without going async
226227
if (ctx.meta.returned || !ctx.meta.resolve) {

src/parsetree/commands/controlflow.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,32 @@ class RepeatLoopCommand extends Command {
7474
}
7575

7676
if (keepLooping) {
77+
var currentIndex = iteratorInfo.index;
7778
if (iteratorInfo.value) {
7879
context.result = context.locals[this.identifier] = loopVal;
7980
} else {
80-
context.result = iteratorInfo.index;
81+
context.result = currentIndex;
8182
}
8283
if (this.indexIdentifier) {
83-
context.locals[this.indexIdentifier] = iteratorInfo.index;
84+
context.locals[this.indexIdentifier] = currentIndex;
8485
}
86+
87+
// In template mode, emit a scope marker so processNode can
88+
// resolve loop variables on elements with _= attributes
89+
if (context.meta.__ht_template_result && iteratorInfo.value) {
90+
var scopes = context.meta.__ht_scopes || (context.meta.__ht_scopes = {});
91+
if (!scopes[this.slot]) {
92+
scopes[this.slot] = {
93+
identifier: this.identifier,
94+
indexIdentifier: this.indexIdentifier,
95+
source: iteratorInfo.value
96+
};
97+
}
98+
context.meta.__ht_template_result.push(
99+
'<!--hs-scope:' + this.slot + ':' + currentIndex + '-->'
100+
);
101+
}
102+
85103
iteratorInfo.didIterate = true;
86104
iteratorInfo.index++;
87105
return this.loop;

src/parsetree/commands/template.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export function initLiveTemplates(runtime, tokenizer, Parser, kernel, reactivity
6262
return "";
6363
}
6464
cmds.execute(ctx);
65+
wrapper.__hs_scopes = ctx.meta.__ht_scopes || null;
6566
if (ctx.meta.returned || !ctx.meta.resolve) return buf.join("");
6667
var resolve;
6768
var promise = new Promise(function(r) { resolve = r; });
@@ -287,10 +288,18 @@ export class RenderCommand extends Command {
287288

288289
commandList.execute(renderCtx);
289290

291+
var scopes = renderCtx.meta.__ht_scopes || null;
292+
var SCOPE_MARKER_RE = /<!--hs-scope:[^>]*-->/g;
290293
var finish = (result) => {
291-
ctx.result = result;
292-
if (this.insertHere) ctx.me.innerHTML = result;
293-
if (target) target.innerHTML = result;
294+
ctx.result = result.replace(SCOPE_MARKER_RE, '');
295+
if (this.insertHere) {
296+
ctx.me.__hs_scopes = scopes;
297+
ctx.me.innerHTML = result;
298+
}
299+
if (target) {
300+
target.__hs_scopes = scopes;
301+
target.innerHTML = result;
302+
}
294303
return runtime.findNext(this, ctx);
295304
};
296305

test/core/liveTemplate.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,57 @@ test.describe('live templates', () => {
128128
await expect.poll(() => find('[data-live-template] span').textContent()).toBe('from script')
129129
})
130130

131+
test('loop variables are captured and available in _= handlers', async ({html, find, page, run}) => {
132+
await run("set $captureItems to [{name:'Alice'},{name:'Bob'},{name:'Charlie'}]")
133+
await html(`
134+
<script type="text/hypertemplate" live>
135+
<ul>
136+
#for item in $captureItems index i
137+
<li _="on click put item.name into me">${"\x24"}{item.name}</li>
138+
#end
139+
</ul>
140+
</script>
141+
`)
142+
await expect.poll(() => find('[data-live-template] li').count()).toBe(3)
143+
await find('[data-live-template] li').nth(1).click()
144+
await expect(find('[data-live-template] li').nth(1)).toHaveText('Bob')
145+
})
146+
147+
test('loop index variable is captured alongside loop variable', async ({html, find, run}) => {
148+
await run("set $idxItems to ['A','B','C']")
149+
await html(`
150+
<script type="text/hypertemplate" live>
151+
<ul>
152+
#for item in $idxItems index i
153+
<li _="on click put i + ':' + item into me">${"\x24"}{item}</li>
154+
#end
155+
</ul>
156+
</script>
157+
`)
158+
await expect.poll(() => find('[data-live-template] li').count()).toBe(3)
159+
await find('[data-live-template] li').nth(1).click()
160+
await expect(find('[data-live-template] li').nth(1)).toHaveText('1:B')
161+
})
162+
163+
test('loop variable capture works with remove for live list', async ({html, find, page, run}) => {
164+
await run("set $removeItems to [{name:'A'},{name:'B'},{name:'C'}]")
165+
await html(`
166+
<script type="text/hypertemplate" live>
167+
<ul>
168+
#for item in $removeItems
169+
<li><span>${"\x24"}{item.name}</span><button _="on click remove item from $removeItems">x</button></li>
170+
#end
171+
</ul>
172+
</script>
173+
`)
174+
await expect.poll(() => find('[data-live-template] li').count()).toBe(3)
175+
// Remove the middle item "B"
176+
await find('[data-live-template] li').nth(1).locator('button').click()
177+
await expect.poll(() => find('[data-live-template] li').count()).toBe(2)
178+
await expect(find('[data-live-template] li').first().locator('span')).toHaveText('A')
179+
await expect(find('[data-live-template] li').last().locator('span')).toHaveText('C')
180+
})
181+
131182
test('script-based live template preserves ${} in bare attribute position', async ({html, find, page}) => {
132183
await html(`
133184
<script type="text/hypertemplate" live _="init set ^items to [{text:'A', done:true},{text:'B', done:false}]">
@@ -143,10 +194,10 @@ test.describe('live templates', () => {
143194
`)
144195
await expect.poll(() => find('[data-live-template] li').count()).toBe(2)
145196
var firstChecked = await page.evaluate(() =>
146-
document.querySelector('[data-live-template] li:nth-child(1) input').checked)
197+
document.querySelector('[data-live-template] li:first-of-type input').checked)
147198
expect(firstChecked).toBe(true)
148199
var secondChecked = await page.evaluate(() =>
149-
document.querySelector('[data-live-template] li:nth-child(2) input').checked)
200+
document.querySelector('[data-live-template] li:last-of-type input').checked)
150201
expect(secondChecked).toBe(false)
151202
await expect(find('[data-live-template] li').first()).toHaveClass(/done/)
152203
await expect(find('[data-live-template] li').last()).not.toHaveClass(/done/)

0 commit comments

Comments
 (0)