From dfda466fb6b0f3264cc2e67066ebf318d9ab563d Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 14:14:07 -0400 Subject: [PATCH 01/16] Feat: Add scope to event handlers --- examples/src/todo-list/component.js | 16 +- examples/src/todo-list/todo-item.html | 8 +- .../src/engines/native/blocks/async.js | 5 + .../src/engines/native/blocks/each.js | 23 +- .../src/engines/native/blocks/template.js | 31 ++ .../src/engines/native/dynamic-region.js | 5 + .../renderer/src/engines/native/renderer.js | 9 + .../src/engines/native/scope-context.js | 109 +++++++ .../test/browser/event-scope-blocks.test.js | 293 ++++++++++++++++++ packages/templating/src/template.js | 55 +++- .../test/browser/event-scope.test.js | 202 ++++++++++++ 11 files changed, 730 insertions(+), 26 deletions(-) create mode 100644 packages/renderer/src/engines/native/scope-context.js create mode 100644 packages/renderer/test/browser/event-scope-blocks.test.js create mode 100644 packages/templating/test/browser/event-scope.test.js diff --git a/examples/src/todo-list/component.js b/examples/src/todo-list/component.js index 00ca3ddf4..f42ff993b 100644 --- a/examples/src/todo-list/component.js +++ b/examples/src/todo-list/component.js @@ -123,19 +123,19 @@ const events = { self.toggleAll(); }, - 'change .toggle'({ self, data }) { - self.toggleTodo(data.id); + 'change .toggle'({ self, scope }) { + self.toggleTodo(scope.id); }, - 'dblclick .todo-list label'({ state, data, $, afterFlush }) { - state.editingId.set(data.id); + 'dblclick .todo-list label'({ state, scope, $, afterFlush }) { + state.editingId.set(scope.id); afterFlush(() => { $('.editing .edit').focus(); }); }, - 'click .destroy'({ self, data }) { - self.deleteTodo(data.id); + 'click .destroy'({ self, scope }) { + self.deleteTodo(scope.id); }, 'keydown .edit'({ target, event }) { @@ -144,9 +144,9 @@ const events = { } }, - 'focusout .edit'({ self, state, data, value }) { + 'focusout .edit'({ self, state, scope, value }) { if (state.editingId.get() !== null) { - self.saveTodo(data.id, value); + self.saveTodo(scope.id, value); } }, diff --git a/examples/src/todo-list/todo-item.html b/examples/src/todo-list/todo-item.html index b46256f15..5bb3af09b 100644 --- a/examples/src/todo-list/todo-item.html +++ b/examples/src/todo-list/todo-item.html @@ -1,10 +1,10 @@
  • - - - + + +
    {#if editing} - + {/if}
  • diff --git a/packages/renderer/src/engines/native/blocks/async.js b/packages/renderer/src/engines/native/blocks/async.js index 78efcb450..2266b0a5e 100644 --- a/packages/renderer/src/engines/native/blocks/async.js +++ b/packages/renderer/src/engines/native/blocks/async.js @@ -1,5 +1,6 @@ import { each, isPlainObject, isPromise } from '@semantic-ui/utils'; import { defineBlock } from '../define-block.js'; +import { markScopeRange } from '../scope-context.js'; import { registerBlock } from './registry.js'; /* @@ -58,6 +59,9 @@ function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { isSVG, }); region.setContent(fragment, stateScope); + // always restamp — loading's empty layer overwrites a stale success + // owner under fresh loading DOM + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: extraData }); }; if (isPromise(result)) { @@ -104,6 +108,7 @@ function renderErrorState(ctx, err) { isSVG, }); region.setContent(fragment, stateScope); + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: errorData }); } const asyncBlock = defineBlock({ diff --git a/packages/renderer/src/engines/native/blocks/each.js b/packages/renderer/src/engines/native/blocks/each.js index 05ca7074e..c5258e526 100644 --- a/packages/renderer/src/engines/native/blocks/each.js +++ b/packages/renderer/src/engines/native/blocks/each.js @@ -4,6 +4,7 @@ import { decodeItemKey, getCollectionType, getEachData, getItemID } from '../../ import { lisIndices } from '../../../shared/lis.js'; import { defineBlock } from '../define-block.js'; import { ReactiveDataContext } from '../reactive-context.js'; +import { markScopeRange } from '../scope-context.js'; import { registerBlock } from './registry.js'; export const SUI_ITEM_MARKER = `sui-item:${MARKER_VERSION}:`; @@ -141,7 +142,7 @@ function snapshotForRecord(item, collectionType) { return collectionType === 'object' && item != null ? createSnapshot(item.value) : createSnapshot(item); } -function createRecord({ key, item, index, collectionType, node, data, scope, renderAST, isSVG }) { +function createRecord({ key, item, index, collectionType, node, data, scope, renderAST, isSVG, regionAnchor }) { const eachData = getEachData(item, index, collectionType, node); const itemScope = scope.child(); const dataContext = new ReactiveDataContext(data, { @@ -159,7 +160,7 @@ function createRecord({ key, item, index, collectionType, node, data, scope, ren const endMarker = document.createTextNode(''); fragment.insertBefore(startMarker, fragment.firstChild); fragment.appendChild(endMarker); - return { + const record = { key, item, index, @@ -178,7 +179,14 @@ function createRecord({ key, item, index, collectionType, node, data, scope, ren // current data and have no stale subscribers to wake) from steady- // state records (which need notifyKey broadcasts on in-place mutation). fresh: true, + // The each block's region anchor — scope resolution jumps here after + // collecting this record's layer, skipping sibling rows in one hop. + regionAnchor, }; + // scope stamps for event-handler resolution: the end stamp jumps a + // backward scan past the whole list, not just this record + markScopeRange(startMarker, endMarker, record, regionAnchor); + return record; } function disposeRecord(record) { @@ -293,6 +301,7 @@ function reconcile({ records, items, collectionType, node, data, scope, region, scope, renderAST, isSVG, + regionAnchor: region.anchor, }); freshCount++; } @@ -506,6 +515,7 @@ function renderElse({ records, node, data, scope, region, renderAST, isSVG }) { isElse: true, snapshot: null, fresh: false, + regionAnchor: null, }); } @@ -632,7 +642,7 @@ function adoptServerItems({ insertAfter.after(fragment); insertAfter = endMarker; - newRecords.push({ + const record = { key, item, index: i, @@ -648,7 +658,10 @@ function adoptServerItems({ // wake. fresh:true would gate it out of the same-ref snapshot-diff // branch and drop that mutation. fresh: false, - }); + regionAnchor: region.anchor, + }; + markScopeRange(startMarker, endMarker, record, region.anchor); + newRecords.push(record); } else { const record = createRecord({ @@ -661,6 +674,7 @@ function adoptServerItems({ scope, renderAST, isSVG, + regionAnchor: region.anchor, }); insertAfter.after(record.fragment); record.fragment = null; @@ -726,6 +740,7 @@ const eachBlock = defineBlock({ isElse: true, snapshot: null, fresh: false, + regionAnchor: null, }); } return; diff --git a/packages/renderer/src/engines/native/blocks/template.js b/packages/renderer/src/engines/native/blocks/template.js index b5c824d35..0d58955d2 100644 --- a/packages/renderer/src/engines/native/blocks/template.js +++ b/packages/renderer/src/engines/native/blocks/template.js @@ -3,6 +3,7 @@ import { Template } from '@semantic-ui/templating'; import { each, extend, fatal, isPlainObject, isString } from '@semantic-ui/utils'; import { defineBlock } from '../define-block.js'; import { isItemContext } from '../reactive-context.js'; +import { DECLARED_KEYS, markScopeRange } from '../scope-context.js'; import { registerBlock } from './registry.js'; /* @@ -158,6 +159,9 @@ function buildArgsRecord({ node, parentData, evaluator, target }) { // up with the same own-property shape. const record = {}; extend(record, target); + // scope resolution reads declared keys only — the copied parent + // descriptors freeze at mount and would mask fresher outer layers + Object.defineProperty(record, DECLARED_KEYS, { value: keys }); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const val = values[i]; @@ -211,6 +215,27 @@ function prepareSnippet({ node, data, self }) { function mountSnippet({ node, data, region, scope, renderAST, isSVG, self }) { const { snippet, snippetData } = prepareSnippet({ node, data, self }); region.setContent(renderAST({ ast: snippet.content, data: snippetData, scope, isSVG })); + stampArgsScope(region, snippetData); +} + +// scope stamps for event-handler resolution. No-arg invocations add no +// layer (snippetData === parent data, no DECLARED_KEYS), empty regions +// have no inside to resolve. +function stampArgsScope(region, argsRecord) { + const keys = argsRecord[DECLARED_KEYS]; + if (!keys || region.ownedNodes.length === 0) { return; } + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: argsRecord, keys }); +} + +// Subtemplates declare args two ways: a reactiveData record installed as +// instance.data (live getters, DECLARED_KEYS stashed) or an eager blob. +// blobKeys arrive captured before render — setDataContext merges state +// and instance onto instance.data in place, so reading keys afterwards +// would leak the whole context into the layer. +function stampInstanceScope(region, instance, blobKeys) { + const keys = instance.data[DECLARED_KEYS] || blobKeys; + if (keys.length === 0 || region.ownedNodes.length === 0) { return; } + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: instance.data, keys }); } // Build the subtemplate's lazy-getter record and clone the Template @@ -363,8 +388,10 @@ const templateBlock = defineBlock({ node, }); setupSettingsMirror({ node, data, scope, region, self }); + const blobKeys = Object.keys(blobData); const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); + stampInstanceScope(region, self.currentInstance, blobKeys); attachToRenderRoot(self.currentInstance, region, self); }, @@ -378,6 +405,7 @@ const templateBlock = defineBlock({ // Snippet args reactivity is anchored on the block scope; a child // would dispose with the next region.clear() and break arg reactivity. hydrateInto({ innerAST: snippet.content, data: snippetData, asChild: false }); + stampArgsScope(region, snippetData); } return; } @@ -410,6 +438,7 @@ const templateBlock = defineBlock({ self.currentInstance.markRendered(); if (region.ownedNodes.length > 0) { + stampInstanceScope(region, self.currentInstance, Object.keys(blobData)); attachToRenderRoot(self.currentInstance, region, self, { startNode: region.ownedNodes[0] }); } }, @@ -457,8 +486,10 @@ const templateBlock = defineBlock({ node, }); setupSettingsMirror({ node, data, scope, region, self }); + const blobKeys = Object.keys(blobData); const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); + stampInstanceScope(region, self.currentInstance, blobKeys); attachToRenderRoot(self.currentInstance, region, self); } else { diff --git a/packages/renderer/src/engines/native/dynamic-region.js b/packages/renderer/src/engines/native/dynamic-region.js index cc2801a4d..ec745aa43 100644 --- a/packages/renderer/src/engines/native/dynamic-region.js +++ b/packages/renderer/src/engines/native/dynamic-region.js @@ -1,3 +1,5 @@ +import { SCOPE_END } from './scope-context.js'; + export class DynamicRegion { constructor(parentNode, marker) { this.parentNode = parentNode; @@ -36,6 +38,9 @@ export class DynamicRegion { this.endAnchor = document.createTextNode(''); } lastNode.after(this.endAnchor); + // closed regions are one backward hop for scope resolution — also + // keeps scans out of region content that contains scope stamps + this.endAnchor[SCOPE_END] = this.anchor; } getLastNode() { diff --git a/packages/renderer/src/engines/native/renderer.js b/packages/renderer/src/engines/native/renderer.js index b976fc7f2..0802ae140 100644 --- a/packages/renderer/src/engines/native/renderer.js +++ b/packages/renderer/src/engines/native/renderer.js @@ -20,6 +20,7 @@ import { bindAttribute } from './attribute-binding.js'; import { getBlock } from './blocks/registry.js'; import { DynamicRegion } from './dynamic-region.js'; import { ReactionScope } from './reaction-scope.js'; +import { resolveScopeLayers } from './scope-context.js'; // import block registry import './blocks/index.js'; @@ -611,4 +612,12 @@ export class Renderer { this.dataDep.changed(); this.notifyUpdate(); } + + // engine contract: block scope layers acting at a DOM position, + // innermost first, or null when the node is outside the template's + // tree. Engines without block scope resolution omit the method and + // event `scope` degrades to an empty object. + resolveScopeLayers(target, bounds) { + return resolveScopeLayers(target, bounds); + } } diff --git a/packages/renderer/src/engines/native/scope-context.js b/packages/renderer/src/engines/native/scope-context.js new file mode 100644 index 000000000..0171a43fc --- /dev/null +++ b/packages/renderer/src/engines/native/scope-context.js @@ -0,0 +1,109 @@ +import { ReactiveDataContext } from './reactive-context.js'; + +/* + + Block scope resolution for event handlers — the `scope` callback param. + + Scope-creating blocks (each records, subtemplates, snippets with args, + async vars) already delimit their DOM ranges with boundary nodes they + maintain: each-record startMarker/endMarker pairs and DynamicRegion + anchor/endAnchor pairs. Those nodes get two symbol expandos: + + SCOPE_OWNER on a range start — resolves to a data layer + SCOPE_END on a range end — carries the node a backward scan jumps + to when the range is closed relative to the target + + Resolution walks up from the event target. At each level a backward + sibling scan bracket-matches over the stamps: a SCOPE_END means the + range closed before the target (jump past it in one hop), a bare + SCOPE_OWNER means the range encloses the target (collect its layer). + Pairs at one sibling level are properly nested or disjoint — markers + move atomically through extractRangeToFragment — so bracket matching + is exact. Content swapped in by inner blocks after creation lands + between the same markers, so it resolves with zero extra bookkeeping, + the same live-sibling invariant each's reconcile relies on. + + Owner shapes: + each record — .dataContext (RDC), layer is the live values + bag; .regionAnchor compresses the rest of the + list to one hop after collection + { data, keys } — subtemplate/snippet/async descriptor; keys + limits the layer to declared args (args records + also carry copied parent descriptors that + freeze at mount and would mask fresher outer + layers) + +*/ + +export const SCOPE_OWNER = Symbol('sui-scope-owner'); +export const SCOPE_END = Symbol('sui-scope-end'); + +// stashed on args records by buildArgsRecord so stamp sites can limit +// the layer to declared keys without recomputing them +export const DECLARED_KEYS = Symbol('sui-declared-keys'); + +export function markScopeRange(startNode, endNode, owner, jumpTarget = startNode) { + startNode[SCOPE_OWNER] = owner; + if (endNode) { + endNode[SCOPE_END] = jumpTarget; + } +} + +function getLayer(owner) { + if (owner.dataContext instanceof ReactiveDataContext) { + return owner.dataContext.values; + } + const { data, keys } = owner; + if (!keys) { + return data; + } + const layer = {}; + for (const key of keys) { + layer[key] = data[key]; + } + return layer; +} + +// Layers innermost-first, or null when the target is not inside +// rootNode's tree (host-surface events, global events). startNode is +// the template's own start anchor — scopes beyond it belong to the +// parent template, not this one. +export function resolveScopeLayers(target, { rootNode, startNode } = {}) { + const layers = []; + let node = target; + while (node && node !== rootNode) { + let sibling = node.previousSibling; + while (sibling) { + if (sibling === startNode) { + return layers; + } + const skipTo = sibling[SCOPE_END]; + if (skipTo !== undefined) { + sibling = skipTo; + } + else { + const owner = sibling[SCOPE_OWNER]; + if (owner !== undefined) { + layers.push(getLayer(owner)); + if (owner.regionAnchor) { + sibling = owner.regionAnchor; + } + } + } + sibling = sibling.previousSibling; + } + const parent = node.parentNode; + if (parent === null) { + // crossing out of a shadow tree — deep targets resolve through + // the host into the handler-owning template's tree + const host = node.host; + if (!host) { + return null; + } + node = host; + continue; + } + node = parent; + } + return node === rootNode ? layers : null; +} diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js new file mode 100644 index 000000000..50ae2486d --- /dev/null +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -0,0 +1,293 @@ +// Block scope layers in the event-handler `scope` param: each item vars, +// subtemplate args, snippet args, async vars, resolved from the event +// target's position via the boundary-marker bracket scan. Layer resolution +// is a native-engine contract — lit degrades to an empty object, asserted +// at the bottom of the engine loop. + +import { defineComponent } from '@semantic-ui/component'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RENDERING_ENGINES, waitForUpdate } from './test-utils.js'; + +RENDERING_ENGINES.forEach((engine) => { + const isLit = engine === 'lit'; + + describe(engine, () => { + let tagCounter = 0; + function uniqueTag() { + return `test-scope-${engine}-${++tagCounter}`; + } + + function clickOn(element) { + element.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }), + ); + } + + async function mount(tag) { + const el = document.createElement(tag); + document.body.appendChild(el); + await el.rendered; + return el; + } + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('event scope — each layers', () => { + it.skipIf(isLit)('exposes as-mode item and index at the clicked row', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '', + defaultState: { + items: [ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ], + }, + events: { + 'click .row'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelectorAll('.row')[1]); + expect(captured.item).toEqual({ id: 'b', name: 'Beta' }); + expect(captured.index).toBe(1); + expect(Object.getPrototypeOf(captured)).toBe(Object.prototype); + }); + + it.skipIf(isLit)('exposes spread-mode fields, this, and index', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '', + defaultState: { + items: [{ id: 'a', name: 'Alpha' }], + }, + events: { + 'click .row'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.row')); + expect(captured.name).toBe('Alpha'); + expect(captured.this).toEqual({ id: 'a', name: 'Alpha' }); + expect(captured.index).toBe(0); + }); + + it.skipIf(isLit)('stays current across reorder and in-place mutation', async () => { + const tag = uniqueTag(); + const a = { id: 'a', name: 'Alpha' }; + const b = { id: 'b', name: 'Beta' }; + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '', + defaultState: { items: [a, b] }, + events: { + 'click .row'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + + el.template.state.items.set([b, a]); + await waitForUpdate(el); + clickOn(el.shadowRoot.querySelectorAll('.row')[0]); + expect(captured.item.id).toBe('b'); + expect(captured.index).toBe(0); + + el.template.state.items.peek()[0].name = 'Beta v2'; + el.template.state.items.notify(); + await waitForUpdate(el); + clickOn(el.shadowRoot.querySelectorAll('.row')[0]); + expect(captured.item.name).toBe('Beta v2'); + }); + + it.skipIf(isLit)('resolves nested each with the inner layer winning', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: + '{#each outer in outers}
    {#each inner in outer.kids}{inner.id}{/each}
    {/each}', + defaultState: { + outers: [ + { id: 'o1', kids: [{ id: 'k1' }, { id: 'k2' }] }, + ], + }, + events: { + 'click .cell'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelectorAll('.cell')[1]); + expect(captured.inner.id).toBe('k2'); + expect(captured.outer.id).toBe('o1'); + expect(captured.index).toBe(1); + }); + + it.skipIf(isLit)('resolves block content swapped in after creation to the row layer', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: + '', + defaultState: { + items: [{ id: 'a', name: 'Alpha' }], + showExtra: false, + }, + events: { + 'click .extra'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + el.template.state.showExtra.set(true); + await waitForUpdate(el); + clickOn(el.shadowRoot.querySelector('.extra')); + expect(captured.item.id).toBe('a'); + }); + + it.skipIf(isLit)('resolves an element after the list to an empty scope', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: + '
    ', + defaultState: { + items: [{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }], + }, + events: { + 'click .after'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.after')); + expect(captured).toEqual({}); + }); + }); + + describe('event scope — subtemplate and snippet layers', () => { + it.skipIf(isLit)('exposes subtemplate args to the parent handler without data attributes', async () => { + const tag = uniqueTag(); + let parentScope; + const rowItem = defineComponent({ + renderingEngine: engine, + template: '
  • {name}
  • ', + }); + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '', + subTemplates: { rowItem }, + defaultState: { + items: [{ id: 7, name: 'Seven' }], + }, + events: { + 'click .lbl'({ scope }) { + parentScope = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.lbl')); + expect(parentScope.id).toBe(7); + expect(parentScope.name).toBe('Seven'); + expect(parentScope.item).toEqual({ id: 7, name: 'Seven' }); + }); + + it.skipIf(isLit)('limits snippet layers to declared args', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '{#snippet badge}{label}{/snippet}
    {>badge label=current}
    ', + defaultState: { + current: 'live', + items: ['parent-key'], + }, + events: { + 'click .badge'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.badge')); + expect(captured.label).toBe('live'); + // parent context keys are not a layer — they already arrive as + // settings/state/self params + expect('items' in captured).toBe(false); + }); + }); + + describe('event scope — async layers', () => { + it.skipIf(isLit)('exposes async as-vars from the current state render', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '{#async getData as result}{result.x}{/async}', + createComponent: () => ({ + getData: () => ({ x: 5 }), + }), + events: { + 'click .res'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.res')); + expect(captured.result).toEqual({ x: 5 }); + }); + }); + + describe('event scope — engine degradation', () => { + it.skipIf(!isLit)('resolves to an empty object on engines without layer resolution', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '', + defaultState: { + items: [{ id: 'a', name: 'Alpha' }], + }, + events: { + 'click .row'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.row')); + expect(captured).toEqual({}); + }); + }); + }); +}); diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index 665cc8e07..bed811390 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -8,6 +8,7 @@ import { match, nonreactive, reaction, + Signal, signal, } from '@semantic-ui/reactivity'; import { @@ -623,18 +624,26 @@ export const Template = class Template { return value; }); const elValue = targetElement?.value ?? event.target?.value ?? event?.detail?.value; - return template.call(boundEvent, { - additionalData: { - event: event, - isDeep, - target: targetElement, - value: elValue, - data: { - ...elData, - ...eventData, - }, + // params built explicitly so `scope` can be a lazy getter — + // call()'s additionalData spread would evaluate it eagerly + const params = { + ...(template.callParams || template.buildCallParams()), + event: event, + isDeep, + target: targetElement, + value: elValue, + data: { + ...elData, + ...eventData, }, + }; + let resolvedScope; + Object.defineProperty(params, 'scope', { + configurable: true, + enumerable: true, + get: () => resolvedScope ??= template.getEventScope(targetElement), }); + return template.call(boundEvent, { params }); }; const domEventSettings = {}; const eventSettings = { abortController: this.eventController, eventSettings: domEventSettings }; @@ -787,6 +796,32 @@ export const Template = class Template { return isNodeInRange(getRootChild(node)); } + // Block scope vars acting at an event target — each item vars, + // subtemplate args, snippet args, async vars — materialized into a + // plain object, innermost layer winning. Resolution is the engine's + // (boundary-marker bracket scan in native); engines without it + // resolve to an empty object. Snapshot semantics: values are copied + // at dispatch, signal values peeked, nothing subscribes. + getEventScope(target) { + return nonreactive(() => { + const eventScope = {}; + const layers = this.renderer?.resolveScopeLayers?.(target, { + rootNode: this.parentNode || this.renderRoot, + startNode: this.startNode, + }); + if (layers) { + for (let i = layers.length - 1; i >= 0; i--) { + const layer = layers[i]; + for (const key in layer) { + const value = layer[key]; + eventScope[key] = (value instanceof Signal) ? value.peek() : value; + } + } + } + return eventScope; + }); + } + render(additionalData = {}) { if (!this.initialized) { this.initialize(); diff --git a/packages/templating/test/browser/event-scope.test.js b/packages/templating/test/browser/event-scope.test.js new file mode 100644 index 000000000..4a6c8eeac --- /dev/null +++ b/packages/templating/test/browser/event-scope.test.js @@ -0,0 +1,202 @@ +// Browser tests for the event-handler `scope` param at the Template level: +// the plain-object contract, lazy resolution, per-dispatch memoization, and +// the empty result for targets under no block scope. Block-layer resolution +// (each/subtemplate/snippet/async) is covered in the renderer suite at +// packages/renderer/test/browser/event-scope-blocks.test.js. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +const RENDER_TARGETS = [ + { name: 'light', target: 'light' }, + { name: 'shadow', target: 'shadow' }, +]; + +async function mountTemplate({ + template = '
    ', + events, + target = 'shadow', + ...opts +} = {}) { + const host = document.createElement('div'); + const renderRoot = target === 'shadow' + ? host.attachShadow({ mode: 'open' }) + : host; + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: { renderer: Renderer, serverRenderer: ServerRenderer }, + element: host, + events, + ...opts, + }); + tpl.initialize(); + await tpl.attach(renderRoot); + return { + host, + renderRoot, + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (_) {} + if (host.parentNode) { + host.parentNode.removeChild(host); + } + }, + }; +} + +function clickOn(element, init = {}) { + element.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +RENDER_TARGETS.forEach(({ name, target }) => { + describe(name, () => { + describe('event scope param', () => { + it('is a plain empty object for targets under no block scope', async () => { + let captured; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'({ scope }) { + captured = scope; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(captured).toEqual({}); + expect(Object.getPrototypeOf(captured)).toBe(Object.prototype); + } + finally { + fixture.cleanup(); + } + }); + + it('resolves lazily — handlers that never read scope never resolve it', async () => { + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'() {}, + }, + }); + fixture.renderRoot.innerHTML = ''; + const spy = vi.spyOn(fixture.template, 'getEventScope'); + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(spy).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('resolves once per dispatch when read repeatedly', async () => { + let first; + let second; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'(params) { + first = params.scope; + second = params.scope; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + const spy = vi.spyOn(fixture.template, 'getEventScope'); + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(spy).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + } + finally { + fixture.cleanup(); + } + }); + + it('leaves the existing handler params unchanged alongside scope', async () => { + let captured; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'({ scope, data, value, target: clicked, event }) { + captured = { scope, data, value, clicked, event }; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + clickOn(btn); + expect(captured.scope).toEqual({}); + expect(captured.data).toEqual({ id: 7 }); + expect(captured.value).toBe('hello'); + expect(captured.clicked).toBe(btn); + expect(captured.event).toBeInstanceOf(MouseEvent); + } + finally { + fixture.cleanup(); + } + }); + }); + }); +}); + +describe('event scope param — non-delegated dialects', () => { + it('resolves to an empty object for naked-selector host events', async () => { + let captured; + const fixture = await mountTemplate({ + events: { + 'click'({ scope }) { + captured = scope; + }, + }, + }); + try { + clickOn(fixture.host); + expect(captured).toEqual({}); + } + finally { + fixture.cleanup(); + } + }); + + it('resolves to an empty object for global events', async () => { + let captured; + const fixture = await mountTemplate({ + events: { + 'global hashchange window'({ scope }) { + captured = scope; + }, + }, + }); + try { + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(captured).toEqual({}); + } + finally { + fixture.cleanup(); + } + }); +}); From 6e43646571788a9a02a30c1d68ea558dad97423c Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 14:27:32 -0400 Subject: [PATCH 02/16] Docs: Add event scope example --- docs/src/content/examples/event-scope.mdx | 9 ++++ .../events/event-scope/component.css | 34 +++++++++++++++ .../events/event-scope/component.html | 10 +++++ .../framework/events/event-scope/component.js | 43 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 docs/src/content/examples/event-scope.mdx create mode 100644 docs/src/examples/framework/events/event-scope/component.css create mode 100644 docs/src/examples/framework/events/event-scope/component.html create mode 100644 docs/src/examples/framework/events/event-scope/component.js diff --git a/docs/src/content/examples/event-scope.mdx b/docs/src/content/examples/event-scope.mdx new file mode 100644 index 000000000..b52b5742d --- /dev/null +++ b/docs/src/content/examples/event-scope.mdx @@ -0,0 +1,9 @@ +--- +title: 'Event Scope' +exampleType: 'component' +category: 'Framework' +subcategory: 'Events' +tags: ['component', 'events', 'scope', 'each', 'getting-started'] +tip: '`scope` resolves the template vars acting at the event target — each item vars, subtemplate args, snippet args — so list handlers never round-trip identity through `data-*` attributes' +description: Reading each item vars from event handlers with scope +--- diff --git a/docs/src/examples/framework/events/event-scope/component.css b/docs/src/examples/framework/events/event-scope/component.css new file mode 100644 index 000000000..61a65e40e --- /dev/null +++ b/docs/src/examples/framework/events/event-scope/component.css @@ -0,0 +1,34 @@ +:host { + display: block; + font-family: var(--page-font); +} + +.playlist { + margin: 0; + padding: 0; + list-style: none; + + .song { + display: flex; + align-items: center; + gap: var(--gap-xs); + padding: var(--padding-3xs) var(--padding-xs); + border: var(--subtle-border); + border-radius: var(--border-radius); + margin-bottom: var(--gap-3xs); + transition: var(--transition); + + &.playing { + border-color: var(--primary-border-color); + background: var(--primary-5); + } + + .number { + color: var(--standard-50); + } + + .title { + flex: 1; + } + } +} diff --git a/docs/src/examples/framework/events/event-scope/component.html b/docs/src/examples/framework/events/event-scope/component.html new file mode 100644 index 000000000..c4b68a0da --- /dev/null +++ b/docs/src/examples/framework/events/event-scope/component.html @@ -0,0 +1,10 @@ + diff --git a/docs/src/examples/framework/events/event-scope/component.js b/docs/src/examples/framework/events/event-scope/component.js new file mode 100644 index 000000000..8c83073ac --- /dev/null +++ b/docs/src/examples/framework/events/event-scope/component.js @@ -0,0 +1,43 @@ +import { defineComponent, getText } from '@semantic-ui/component'; + +const css = await getText('./component.css'); +const template = await getText('./component.html'); + +const defaultState = { + nowPlaying: null, + songs: [ + { id: 1, title: 'Pink Moon' }, + { id: 2, title: 'Harvest' }, + { id: 3, title: 'Karma Police' }, + { id: 4, title: 'Holocene' }, + ], +}; + +const createComponent = ({ state }) => ({ + + playingClass(id) { + return state.nowPlaying.get() === id ? 'playing' : ''; + }, + +}); + +const events = { + + // scope holds the template vars at the clicked row + // i.e. {#each song in songs} provides scope.song and scope.index + 'click .play'({ state, scope }) { + state.nowPlaying.set(scope.song.id); + }, + 'click .remove'({ state, scope }) { + state.songs.removeItem(scope.song.id); + }, +}; + +defineComponent({ + tagName: 'play-list', + template, + css, + defaultState, + events, + createComponent, +}); From e0b4a20f992e44e39615de1a1d1dbfa23f8279eb Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:26:14 -0400 Subject: [PATCH 03/16] Bug: Use live parent for scope boundary --- .../renderer/test/browser/event-scope-blocks.test.js | 9 +++++++++ packages/templating/src/template.js | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js index 50ae2486d..95d53e682 100644 --- a/packages/renderer/test/browser/event-scope-blocks.test.js +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -193,9 +193,15 @@ RENDERING_ENGINES.forEach((engine) => { it.skipIf(isLit)('exposes subtemplate args to the parent handler without data attributes', async () => { const tag = uniqueTag(); let parentScope; + let ownScope; const rowItem = defineComponent({ renderingEngine: engine, template: '
  • {name}
  • ', + events: { + 'click .lbl'({ scope }) { + ownScope = scope; + }, + }, }); defineComponent({ tagName: tag, @@ -216,6 +222,9 @@ RENDERING_ENGINES.forEach((engine) => { expect(parentScope.id).toBe(7); expect(parentScope.name).toBe('Seven'); expect(parentScope.item).toEqual({ id: 7, name: 'Seven' }); + // the subtemplate's own handler sees its own tree only — the + // parent's row vars sit beyond its start anchor + expect(ownScope).toEqual({}); }); it.skipIf(isLit)('limits snippet layers to declared args', async () => { diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index 84b8d9844..b099e47da 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -810,7 +810,9 @@ export const Template = class Template { return nonreactive(() => { const eventScope = {}; const layers = this.renderer?.resolveScopeLayers?.(target, { - rootNode: this.parentNode || this.renderRoot, + // same live-boundary rule as isNodeInTemplate — stored parentNode + // can be a dead build fragment for subtemplates attached mid-render + rootNode: this.startNode?.parentNode || this.parentNode || this.renderRoot, startNode: this.startNode, }); if (layers) { From 5e93967eac1b2d05ce196d6c5c6bc9fbf3990009 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:44:58 -0400 Subject: [PATCH 04/16] Refactor: Drop the regionAnchor list-skip from scope resolution --- .../renderer/src/engines/native/blocks/each.js | 17 ++++------------- .../src/engines/native/scope-context.js | 11 +++-------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/packages/renderer/src/engines/native/blocks/each.js b/packages/renderer/src/engines/native/blocks/each.js index c5258e526..42ff87811 100644 --- a/packages/renderer/src/engines/native/blocks/each.js +++ b/packages/renderer/src/engines/native/blocks/each.js @@ -142,7 +142,7 @@ function snapshotForRecord(item, collectionType) { return collectionType === 'object' && item != null ? createSnapshot(item.value) : createSnapshot(item); } -function createRecord({ key, item, index, collectionType, node, data, scope, renderAST, isSVG, regionAnchor }) { +function createRecord({ key, item, index, collectionType, node, data, scope, renderAST, isSVG }) { const eachData = getEachData(item, index, collectionType, node); const itemScope = scope.child(); const dataContext = new ReactiveDataContext(data, { @@ -179,13 +179,9 @@ function createRecord({ key, item, index, collectionType, node, data, scope, ren // current data and have no stale subscribers to wake) from steady- // state records (which need notifyKey broadcasts on in-place mutation). fresh: true, - // The each block's region anchor — scope resolution jumps here after - // collecting this record's layer, skipping sibling rows in one hop. - regionAnchor, }; - // scope stamps for event-handler resolution: the end stamp jumps a - // backward scan past the whole list, not just this record - markScopeRange(startMarker, endMarker, record, regionAnchor); + // scope stamps for event-handler resolution + markScopeRange(startMarker, endMarker, record); return record; } @@ -301,7 +297,6 @@ function reconcile({ records, items, collectionType, node, data, scope, region, scope, renderAST, isSVG, - regionAnchor: region.anchor, }); freshCount++; } @@ -515,7 +510,6 @@ function renderElse({ records, node, data, scope, region, renderAST, isSVG }) { isElse: true, snapshot: null, fresh: false, - regionAnchor: null, }); } @@ -658,9 +652,8 @@ function adoptServerItems({ // wake. fresh:true would gate it out of the same-ref snapshot-diff // branch and drop that mutation. fresh: false, - regionAnchor: region.anchor, }; - markScopeRange(startMarker, endMarker, record, region.anchor); + markScopeRange(startMarker, endMarker, record); newRecords.push(record); } else { @@ -674,7 +667,6 @@ function adoptServerItems({ scope, renderAST, isSVG, - regionAnchor: region.anchor, }); insertAfter.after(record.fragment); record.fragment = null; @@ -740,7 +732,6 @@ const eachBlock = defineBlock({ isElse: true, snapshot: null, fresh: false, - regionAnchor: null, }); } return; diff --git a/packages/renderer/src/engines/native/scope-context.js b/packages/renderer/src/engines/native/scope-context.js index 0171a43fc..02e72cf24 100644 --- a/packages/renderer/src/engines/native/scope-context.js +++ b/packages/renderer/src/engines/native/scope-context.js @@ -24,9 +24,7 @@ import { ReactiveDataContext } from './reactive-context.js'; the same live-sibling invariant each's reconcile relies on. Owner shapes: - each record — .dataContext (RDC), layer is the live values - bag; .regionAnchor compresses the rest of the - list to one hop after collection + each record — .dataContext (RDC), layer is the live values bag { data, keys } — subtemplate/snippet/async descriptor; keys limits the layer to declared args (args records also carry copied parent descriptors that @@ -42,10 +40,10 @@ export const SCOPE_END = Symbol('sui-scope-end'); // the layer to declared keys without recomputing them export const DECLARED_KEYS = Symbol('sui-declared-keys'); -export function markScopeRange(startNode, endNode, owner, jumpTarget = startNode) { +export function markScopeRange(startNode, endNode, owner) { startNode[SCOPE_OWNER] = owner; if (endNode) { - endNode[SCOPE_END] = jumpTarget; + endNode[SCOPE_END] = startNode; } } @@ -85,9 +83,6 @@ export function resolveScopeLayers(target, { rootNode, startNode } = {}) { const owner = sibling[SCOPE_OWNER]; if (owner !== undefined) { layers.push(getLayer(owner)); - if (owner.regionAnchor) { - sibling = owner.regionAnchor; - } } } sibling = sibling.previousSibling; From bdce30c51d53deb7f5f93379270fe359daaa386d Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:50:34 -0400 Subject: [PATCH 05/16] Bug: Clear stale scope owner stamp on region clear --- .../src/engines/native/blocks/async.js | 2 -- .../src/engines/native/dynamic-region.js | 6 +++- .../test/browser/event-scope-blocks.test.js | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/renderer/src/engines/native/blocks/async.js b/packages/renderer/src/engines/native/blocks/async.js index 2266b0a5e..b8d5eaf54 100644 --- a/packages/renderer/src/engines/native/blocks/async.js +++ b/packages/renderer/src/engines/native/blocks/async.js @@ -59,8 +59,6 @@ function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { isSVG, }); region.setContent(fragment, stateScope); - // always restamp — loading's empty layer overwrites a stale success - // owner under fresh loading DOM markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: extraData }); }; diff --git a/packages/renderer/src/engines/native/dynamic-region.js b/packages/renderer/src/engines/native/dynamic-region.js index ec745aa43..dc6517208 100644 --- a/packages/renderer/src/engines/native/dynamic-region.js +++ b/packages/renderer/src/engines/native/dynamic-region.js @@ -1,4 +1,4 @@ -import { SCOPE_END } from './scope-context.js'; +import { SCOPE_END, SCOPE_OWNER } from './scope-context.js'; export class DynamicRegion { constructor(parentNode, marker) { @@ -18,6 +18,10 @@ export class DynamicRegion { this.ownedNodes = []; // endAnchor is reusable across fills. if (this.endAnchor) { this.endAnchor.remove(); } + // the owner stamp dies with the content — the endAnchor's removal + // takes the END jump with it, so a stale owner here would leak the + // old layer into later siblings' scans + this.anchor[SCOPE_OWNER] = undefined; } setContent(fragment, scope) { diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js index 95d53e682..adb2da5d2 100644 --- a/packages/renderer/test/browser/event-scope-blocks.test.js +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -251,6 +251,34 @@ RENDERING_ENGINES.forEach((engine) => { // settings/state/self params expect('items' in captured).toBe(false); }); + + it.skipIf(isLit)('drops the subtemplate layer once the invocation clears', async () => { + const tag = uniqueTag(); + let captured; + const rowItem = defineComponent({ + renderingEngine: engine, + template: '{id}', + }); + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '
    {>template name=maybeRow data={id: 7}}
    ', + subTemplates: { rowItem }, + defaultState: { maybeRow: 'rowItem' }, + events: { + 'click .after'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + expect(el.shadowRoot.querySelector('.lbl')).not.toBeNull(); + el.template.state.maybeRow.set(null); + await waitForUpdate(el); + expect(el.shadowRoot.querySelector('.lbl')).toBeNull(); + clickOn(el.shadowRoot.querySelector('.after')); + expect(captured).toEqual({}); + }); }); describe('event scope — async layers', () => { From e5b299f59a2a9f6af62d62d63cc9a69ae4e38779 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:52:33 -0400 Subject: [PATCH 06/16] Bug: Skip scope stamps on empty async fills --- .../src/engines/native/blocks/async.js | 11 ++++++-- .../test/browser/event-scope-blocks.test.js | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/renderer/src/engines/native/blocks/async.js b/packages/renderer/src/engines/native/blocks/async.js index b8d5eaf54..e492babf3 100644 --- a/packages/renderer/src/engines/native/blocks/async.js +++ b/packages/renderer/src/engines/native/blocks/async.js @@ -24,6 +24,13 @@ import { registerBlock } from './registry.js'; */ +// empty fills have no inside to resolve — and stamping one would land the +// END jump on the anchor itself, masking the owner stamp of a later fill +function stampAsyncScope(region, data) { + if (region.ownedNodes.length === 0) { return; } + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data }); +} + function createSuccessDataContext(node, value) { if (node.as) { return { [node.as]: value }; } if (node.parts && isPlainObject(value)) { @@ -59,7 +66,7 @@ function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { isSVG, }); region.setContent(fragment, stateScope); - markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: extraData }); + stampAsyncScope(region, extraData); }; if (isPromise(result)) { @@ -106,7 +113,7 @@ function renderErrorState(ctx, err) { isSVG, }); region.setContent(fragment, stateScope); - markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: errorData }); + stampAsyncScope(region, errorData); } const asyncBlock = defineBlock({ diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js index adb2da5d2..1d2abc6ea 100644 --- a/packages/renderer/test/browser/event-scope-blocks.test.js +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -302,6 +302,34 @@ RENDERING_ENGINES.forEach((engine) => { clickOn(el.shadowRoot.querySelector('.res')); expect(captured.result).toEqual({ x: 5 }); }); + + it.skipIf(isLit)('stamps error vars after an empty success fill', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: '{#async check}{error as err}{/async}', + defaultState: { fail: false }, + createComponent: ({ state }) => ({ + check: () => state.fail.get() ? Promise.reject(new Error('boom')) : 'ok', + }), + events: { + 'click .boom'({ scope }) { + captured = scope; + }, + }, + }); + const el = await mount(tag); + el.template.state.fail.set(true); + await waitForUpdate(el); + await waitForUpdate(el); + const button = el.shadowRoot.querySelector('.boom'); + expect(button).not.toBeNull(); + clickOn(button); + expect(captured.err).toBeInstanceOf(Error); + expect(captured.err.message).toBe('boom'); + }); }); describe('event scope — engine degradation', () => { From 0717e1f1f71b03ceef704b8ca307446ac1a30491 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:55:00 -0400 Subject: [PATCH 07/16] Refactor: Carry declared keys on instance data from construction --- .../src/engines/native/blocks/template.js | 43 ++++++++----------- .../test/browser/ssr-hydration.test.js | 29 +++++++++++++ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/renderer/src/engines/native/blocks/template.js b/packages/renderer/src/engines/native/blocks/template.js index 0d58955d2..b000ef0a1 100644 --- a/packages/renderer/src/engines/native/blocks/template.js +++ b/packages/renderer/src/engines/native/blocks/template.js @@ -215,27 +215,17 @@ function prepareSnippet({ node, data, self }) { function mountSnippet({ node, data, region, scope, renderAST, isSVG, self }) { const { snippet, snippetData } = prepareSnippet({ node, data, self }); region.setContent(renderAST({ ast: snippet.content, data: snippetData, scope, isSVG })); - stampArgsScope(region, snippetData); + stampScope(region, snippetData); } -// scope stamps for event-handler resolution. No-arg invocations add no -// layer (snippetData === parent data, no DECLARED_KEYS), empty regions -// have no inside to resolve. -function stampArgsScope(region, argsRecord) { - const keys = argsRecord[DECLARED_KEYS]; - if (!keys || region.ownedNodes.length === 0) { return; } - markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: argsRecord, keys }); -} - -// Subtemplates declare args two ways: a reactiveData record installed as -// instance.data (live getters, DECLARED_KEYS stashed) or an eager blob. -// blobKeys arrive captured before render — setDataContext merges state -// and instance onto instance.data in place, so reading keys afterwards -// would leak the whole context into the layer. -function stampInstanceScope(region, instance, blobKeys) { - const keys = instance.data[DECLARED_KEYS] || blobKeys; - if (keys.length === 0 || region.ownedNodes.length === 0) { return; } - markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data: instance.data, keys }); +// scope stamps for event-handler resolution. Data records carry their +// declared keys from construction (buildArgsRecord, cloneInstance) — +// no-arg invocations carry none and add no layer, empty regions have +// no inside to resolve. +function stampScope(region, data) { + const keys = data[DECLARED_KEYS]; + if (!keys?.length || region.ownedNodes.length === 0) { return; } + markScopeRange(region.anchor, region.endAnchor || region.getLastNode(), { data, keys }); } // Build the subtemplate's lazy-getter record and clone the Template @@ -268,6 +258,11 @@ function cloneInstance({ template, templateName, templateData, self, parentData, const record = buildArgsRecord({ node, parentData, evaluator: self.evaluator, target: instance.data }); instance.data = record; } + else { + // blob keys recorded before setDataContext merges the full context + // onto this object — reading them later would leak it into the layer + Object.defineProperty(instance.data, DECLARED_KEYS, { value: Object.keys(instance.data) }); + } nonreactive(() => instance.initialize()); return instance; @@ -388,10 +383,9 @@ const templateBlock = defineBlock({ node, }); setupSettingsMirror({ node, data, scope, region, self }); - const blobKeys = Object.keys(blobData); const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); - stampInstanceScope(region, self.currentInstance, blobKeys); + stampScope(region, self.currentInstance.data); attachToRenderRoot(self.currentInstance, region, self); }, @@ -405,7 +399,7 @@ const templateBlock = defineBlock({ // Snippet args reactivity is anchored on the block scope; a child // would dispose with the next region.clear() and break arg reactivity. hydrateInto({ innerAST: snippet.content, data: snippetData, asChild: false }); - stampArgsScope(region, snippetData); + stampScope(region, snippetData); } return; } @@ -438,7 +432,7 @@ const templateBlock = defineBlock({ self.currentInstance.markRendered(); if (region.ownedNodes.length > 0) { - stampInstanceScope(region, self.currentInstance, Object.keys(blobData)); + stampScope(region, self.currentInstance.data); attachToRenderRoot(self.currentInstance, region, self, { startNode: region.ownedNodes[0] }); } }, @@ -486,10 +480,9 @@ const templateBlock = defineBlock({ node, }); setupSettingsMirror({ node, data, scope, region, self }); - const blobKeys = Object.keys(blobData); const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); - stampInstanceScope(region, self.currentInstance, blobKeys); + stampScope(region, self.currentInstance.data); attachToRenderRoot(self.currentInstance, region, self); } else { diff --git a/packages/renderer/test/browser/ssr-hydration.test.js b/packages/renderer/test/browser/ssr-hydration.test.js index 98d9b4e71..6f969acad 100644 --- a/packages/renderer/test/browser/ssr-hydration.test.js +++ b/packages/renderer/test/browser/ssr-hydration.test.js @@ -470,6 +470,35 @@ describe('SSR hydration — subtemplates', () => { expect(shadowHTML(el)).toBe('
    A
    B
    C
    '); }); + + it('limits a hydrated subtemplate scope layer to declared blob keys', async () => { + let captured; + const child = defineComponent({ + renderingEngine: 'native', + template: '', + }); + + const el = await ssrAndHydrate({ + template: '
    {>template name="child" data={id: rowId, name: rowName}}
    ', + subTemplates: { child }, + defaultState: { rowId: 7, rowName: 'Seven', secret: 'hidden' }, + events: { + 'click .lbl'({ scope }) { + captured = scope; + }, + }, + }); + + el.shadowRoot.querySelector('.lbl').dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }), + ); + expect(captured.id).toBe(7); + expect(captured.name).toBe('Seven'); + // the layer holds declared args only — context keys already arrive + // as state/settings/self params + expect('secret' in captured).toBe(false); + expect('rowId' in captured).toBe(false); + }); }); /******************************* From 60f4a5eeafa24a8d215509b854de883a506e9814 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 15:55:45 -0400 Subject: [PATCH 08/16] Refactor: Simplify scope stamp surfaces --- packages/renderer/src/engines/native/dynamic-region.js | 6 +++--- packages/renderer/src/engines/native/scope-context.js | 4 +--- packages/templating/src/template.js | 10 ++++------ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/renderer/src/engines/native/dynamic-region.js b/packages/renderer/src/engines/native/dynamic-region.js index dc6517208..642066c62 100644 --- a/packages/renderer/src/engines/native/dynamic-region.js +++ b/packages/renderer/src/engines/native/dynamic-region.js @@ -40,11 +40,11 @@ export class DynamicRegion { if (!lastNode) { return; } if (!this.endAnchor) { this.endAnchor = document.createTextNode(''); + // closed regions are one backward hop for scope resolution — also + // keeps scans out of region content that contains scope stamps + this.endAnchor[SCOPE_END] = this.anchor; } lastNode.after(this.endAnchor); - // closed regions are one backward hop for scope resolution — also - // keeps scans out of region content that contains scope stamps - this.endAnchor[SCOPE_END] = this.anchor; } getLastNode() { diff --git a/packages/renderer/src/engines/native/scope-context.js b/packages/renderer/src/engines/native/scope-context.js index 02e72cf24..def35104a 100644 --- a/packages/renderer/src/engines/native/scope-context.js +++ b/packages/renderer/src/engines/native/scope-context.js @@ -1,5 +1,3 @@ -import { ReactiveDataContext } from './reactive-context.js'; - /* Block scope resolution for event handlers — the `scope` callback param. @@ -48,7 +46,7 @@ export function markScopeRange(startNode, endNode, owner) { } function getLayer(owner) { - if (owner.dataContext instanceof ReactiveDataContext) { + if (owner.dataContext) { return owner.dataContext.values; } const { data, keys } = owner; diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index b099e47da..a1968d129 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -626,6 +626,7 @@ export const Template = class Template { const elValue = targetElement?.value ?? event.target?.value ?? event?.detail?.value; // params built explicitly so `scope` can be a lazy getter — // call()'s additionalData spread would evaluate it eagerly + let resolvedScope; const params = { ...(template.callParams || template.buildCallParams()), event: event, @@ -636,13 +637,10 @@ export const Template = class Template { ...elData, ...eventData, }, + get scope() { + return resolvedScope ??= template.getEventScope(targetElement); + }, }; - let resolvedScope; - Object.defineProperty(params, 'scope', { - configurable: true, - enumerable: true, - get: () => resolvedScope ??= template.getEventScope(targetElement), - }); return template.call(boundEvent, { params }); }; const domEventSettings = {}; From 0b91aa2b048f884a57abe4f22c5ac10152918511 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 17:02:27 -0400 Subject: [PATCH 09/16] Feat: Merge template scope into event data --- examples/src/todo-list/component.js | 16 ++-- .../test/browser/event-scope-blocks.test.js | 93 +++++++++++++------ .../test/browser/ssr-hydration.test.js | 4 +- packages/templating/src/template.js | 67 ++++++------- ...event-scope.test.js => event-data.test.js} | 90 ++++++------------ 5 files changed, 128 insertions(+), 142 deletions(-) rename packages/templating/test/browser/{event-scope.test.js => event-data.test.js} (55%) diff --git a/examples/src/todo-list/component.js b/examples/src/todo-list/component.js index f42ff993b..00ca3ddf4 100644 --- a/examples/src/todo-list/component.js +++ b/examples/src/todo-list/component.js @@ -123,19 +123,19 @@ const events = { self.toggleAll(); }, - 'change .toggle'({ self, scope }) { - self.toggleTodo(scope.id); + 'change .toggle'({ self, data }) { + self.toggleTodo(data.id); }, - 'dblclick .todo-list label'({ state, scope, $, afterFlush }) { - state.editingId.set(scope.id); + 'dblclick .todo-list label'({ state, data, $, afterFlush }) { + state.editingId.set(data.id); afterFlush(() => { $('.editing .edit').focus(); }); }, - 'click .destroy'({ self, scope }) { - self.deleteTodo(scope.id); + 'click .destroy'({ self, data }) { + self.deleteTodo(data.id); }, 'keydown .edit'({ target, event }) { @@ -144,9 +144,9 @@ const events = { } }, - 'focusout .edit'({ self, state, scope, value }) { + 'focusout .edit'({ self, state, data, value }) { if (state.editingId.get() !== null) { - self.saveTodo(scope.id, value); + self.saveTodo(data.id, value); } }, diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js index 1d2abc6ea..038e66d9a 100644 --- a/packages/renderer/test/browser/event-scope-blocks.test.js +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -1,7 +1,8 @@ -// Block scope layers in the event-handler `scope` param: each item vars, +// Block scope layers in the event-handler `data` param: each item vars, // subtemplate args, snippet args, async vars, resolved from the event -// target's position via the boundary-marker bracket scan. Layer resolution -// is a native-engine contract — lit degrades to an empty object, asserted +// target's position via the boundary-marker bracket scan and merged into +// data above data-* attributes and below event.detail. Layer resolution +// is a native-engine contract — lit contributes no scope keys, asserted // at the bottom of the engine loop. import { defineComponent } from '@semantic-ui/component'; @@ -49,8 +50,8 @@ RENDERING_ENGINES.forEach((engine) => { ], }, events: { - 'click .row'({ scope }) { - captured = scope; + 'click .row'({ data }) { + captured = data; }, }, }); @@ -72,8 +73,8 @@ RENDERING_ENGINES.forEach((engine) => { items: [{ id: 'a', name: 'Alpha' }], }, events: { - 'click .row'({ scope }) { - captured = scope; + 'click .row'({ data }) { + captured = data; }, }, }); @@ -95,8 +96,8 @@ RENDERING_ENGINES.forEach((engine) => { template: '
      {#each item in items}
    • {item.name}
    • {/each}
    ', defaultState: { items: [a, b] }, events: { - 'click .row'({ scope }) { - captured = scope; + 'click .row'({ data }) { + captured = data; }, }, }); @@ -129,8 +130,8 @@ RENDERING_ENGINES.forEach((engine) => { ], }, events: { - 'click .cell'({ scope }) { - captured = scope; + 'click .cell'({ data }) { + captured = data; }, }, }); @@ -154,8 +155,8 @@ RENDERING_ENGINES.forEach((engine) => { showExtra: false, }, events: { - 'click .extra'({ scope }) { - captured = scope; + 'click .extra'({ data }) { + captured = data; }, }, }); @@ -178,8 +179,8 @@ RENDERING_ENGINES.forEach((engine) => { items: [{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }], }, events: { - 'click .after'({ scope }) { - captured = scope; + 'click .after'({ data }) { + captured = data; }, }, }); @@ -187,6 +188,40 @@ RENDERING_ENGINES.forEach((engine) => { clickOn(el.shadowRoot.querySelector('.after')); expect(captured).toEqual({}); }); + + it.skipIf(isLit)('merges scope over data attributes and detail over scope', async () => { + const tag = uniqueTag(); + let captured; + defineComponent({ + tagName: tag, + renderingEngine: engine, + template: + '
      {#each items}
    • {name}
    • {/each}
    ', + defaultState: { + items: [{ id: 'from-scope', name: 'Alpha' }], + }, + events: { + 'click .row'({ data }) { + captured = data; + }, + }, + }); + const el = await mount(tag); + const row = el.shadowRoot.querySelector('.row'); + clickOn(row); + expect(captured.id).toBe('from-scope'); + expect(captured.extra).toBe('attr-only'); + + row.dispatchEvent( + new CustomEvent('click', { + bubbles: true, + composed: true, + detail: { id: 'from-detail' }, + }), + ); + expect(captured.id).toBe('from-detail'); + expect(captured.name).toBe('Alpha'); + }); }); describe('event scope — subtemplate and snippet layers', () => { @@ -198,8 +233,8 @@ RENDERING_ENGINES.forEach((engine) => { renderingEngine: engine, template: '
  • {name}
  • ', events: { - 'click .lbl'({ scope }) { - ownScope = scope; + 'click .lbl'({ data }) { + ownScope = data; }, }, }); @@ -212,8 +247,8 @@ RENDERING_ENGINES.forEach((engine) => { items: [{ id: 7, name: 'Seven' }], }, events: { - 'click .lbl'({ scope }) { - parentScope = scope; + 'click .lbl'({ data }) { + parentScope = data; }, }, }); @@ -239,8 +274,8 @@ RENDERING_ENGINES.forEach((engine) => { items: ['parent-key'], }, events: { - 'click .badge'({ scope }) { - captured = scope; + 'click .badge'({ data }) { + captured = data; }, }, }); @@ -266,8 +301,8 @@ RENDERING_ENGINES.forEach((engine) => { subTemplates: { rowItem }, defaultState: { maybeRow: 'rowItem' }, events: { - 'click .after'({ scope }) { - captured = scope; + 'click .after'({ data }) { + captured = data; }, }, }); @@ -293,8 +328,8 @@ RENDERING_ENGINES.forEach((engine) => { getData: () => ({ x: 5 }), }), events: { - 'click .res'({ scope }) { - captured = scope; + 'click .res'({ data }) { + captured = data; }, }, }); @@ -315,8 +350,8 @@ RENDERING_ENGINES.forEach((engine) => { check: () => state.fail.get() ? Promise.reject(new Error('boom')) : 'ok', }), events: { - 'click .boom'({ scope }) { - captured = scope; + 'click .boom'({ data }) { + captured = data; }, }, }); @@ -344,8 +379,8 @@ RENDERING_ENGINES.forEach((engine) => { items: [{ id: 'a', name: 'Alpha' }], }, events: { - 'click .row'({ scope }) { - captured = scope; + 'click .row'({ data }) { + captured = data; }, }, }); diff --git a/packages/renderer/test/browser/ssr-hydration.test.js b/packages/renderer/test/browser/ssr-hydration.test.js index 6f969acad..8d4a264c3 100644 --- a/packages/renderer/test/browser/ssr-hydration.test.js +++ b/packages/renderer/test/browser/ssr-hydration.test.js @@ -483,8 +483,8 @@ describe('SSR hydration — subtemplates', () => { subTemplates: { child }, defaultState: { rowId: 7, rowName: 'Seven', secret: 'hidden' }, events: { - 'click .lbl'({ scope }) { - captured = scope; + 'click .lbl'({ data }) { + captured = data; }, }, }); diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index a1968d129..d3626c238 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -611,37 +611,16 @@ export const Template = class Template { // prepare data for users event handler const targetElement = this; const boundEvent = userHandler.bind(targetElement); - const eventData = event?.detail || {}; - // dataset is always stringified for atts, we want this as native values - const elData = mapObject({ ...targetElement?.dataset }, (stringValue) => { - let value; - try { - value = JSON.parse(stringValue); - } - catch (e) { - value = stringValue; - } - return value; - }); const elValue = targetElement?.value ?? event.target?.value ?? event?.detail?.value; - // params built explicitly so `scope` can be a lazy getter — - // call()'s additionalData spread would evaluate it eagerly - let resolvedScope; - const params = { - ...(template.callParams || template.buildCallParams()), - event: event, - isDeep, - target: targetElement, - value: elValue, - data: { - ...elData, - ...eventData, - }, - get scope() { - return resolvedScope ??= template.getEventScope(targetElement); + return template.call(boundEvent, { + additionalData: { + event: event, + isDeep, + target: targetElement, + value: elValue, + data: template.getEventData(targetElement, event), }, - }; - return template.call(boundEvent, { params }); + }); }; const domEventSettings = {}; const eventSettings = { abortController: this.eventController, eventSettings: domEventSettings }; @@ -798,15 +777,25 @@ export const Template = class Template { return isNodeInRange(getRootChild(node)); } - // Block scope vars acting at an event target — each item vars, - // subtemplate args, snippet args, async vars — materialized into a - // plain object, innermost layer winning. Resolution is the engine's - // (boundary-marker bracket scan in native); engines without it - // resolve to an empty object. Snapshot semantics: values are copied - // at dispatch, signal values peeked, nothing subscribes. - getEventScope(target) { + // Everything acting at an event target, merged into one bag: + // data-* attributes (JSON-parsed), block scope vars (each item vars, + // subtemplate args, snippet args, async vars) with the innermost + // layer winning, then event.detail — later sources win on shared + // keys. Scope resolution is the engine's (boundary-marker bracket + // scan in native); engines without it contribute no scope keys. + // Snapshot semantics: values are copied at dispatch, signal values + // peeked, nothing subscribes. + getEventData(target, event) { return nonreactive(() => { - const eventScope = {}; + // dataset is always stringified for atts, we want native values + const eventData = mapObject({ ...target?.dataset }, (stringValue) => { + try { + return JSON.parse(stringValue); + } + catch (e) { + return stringValue; + } + }); const layers = this.renderer?.resolveScopeLayers?.(target, { // same live-boundary rule as isNodeInTemplate — stored parentNode // can be a dead build fragment for subtemplates attached mid-render @@ -818,11 +807,11 @@ export const Template = class Template { const layer = layers[i]; for (const key in layer) { const value = layer[key]; - eventScope[key] = (value instanceof Signal) ? value.peek() : value; + eventData[key] = (value instanceof Signal) ? value.peek() : value; } } } - return eventScope; + return Object.assign(eventData, event?.detail); }); } diff --git a/packages/templating/test/browser/event-scope.test.js b/packages/templating/test/browser/event-data.test.js similarity index 55% rename from packages/templating/test/browser/event-scope.test.js rename to packages/templating/test/browser/event-data.test.js index 4a6c8eeac..19d613d16 100644 --- a/packages/templating/test/browser/event-scope.test.js +++ b/packages/templating/test/browser/event-data.test.js @@ -1,10 +1,11 @@ -// Browser tests for the event-handler `scope` param at the Template level: -// the plain-object contract, lazy resolution, per-dispatch memoization, and -// the empty result for targets under no block scope. Block-layer resolution -// (each/subtemplate/snippet/async) is covered in the renderer suite at +// Browser tests for the event-handler `data` param at the Template level: +// the merged-bag contract (data attributes, block scope, event.detail — +// later sources win) and the empty result for targets carrying none of +// the three. Block-layer resolution (each/subtemplate/snippet/async) is +// covered in the renderer suite at // packages/renderer/test/browser/event-scope-blocks.test.js. -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; import { Template } from '@semantic-ui/templating'; @@ -71,14 +72,14 @@ function clickOn(element, init = {}) { RENDER_TARGETS.forEach(({ name, target }) => { describe(name, () => { - describe('event scope param', () => { - it('is a plain empty object for targets under no block scope', async () => { + describe('event data param', () => { + it('is a plain empty object when no source contributes', async () => { let captured; const fixture = await mountTemplate({ target, events: { - 'click .btn'({ scope }) { - captured = scope; + 'click .btn'({ data }) { + captured = data; }, }, }); @@ -93,67 +94,28 @@ RENDER_TARGETS.forEach(({ name, target }) => { } }); - it('resolves lazily — handlers that never read scope never resolve it', async () => { - const fixture = await mountTemplate({ - target, - events: { - 'click .btn'() {}, - }, - }); - fixture.renderRoot.innerHTML = ''; - const spy = vi.spyOn(fixture.template, 'getEventScope'); - try { - clickOn(fixture.renderRoot.querySelector('.btn')); - expect(spy).not.toHaveBeenCalled(); - } - finally { - fixture.cleanup(); - } - }); - - it('resolves once per dispatch when read repeatedly', async () => { - let first; - let second; - const fixture = await mountTemplate({ - target, - events: { - 'click .btn'(params) { - first = params.scope; - second = params.scope; - }, - }, - }); - fixture.renderRoot.innerHTML = ''; - const spy = vi.spyOn(fixture.template, 'getEventScope'); - try { - clickOn(fixture.renderRoot.querySelector('.btn')); - expect(spy).toHaveBeenCalledTimes(1); - expect(first).toBe(second); - } - finally { - fixture.cleanup(); - } - }); - - it('leaves the existing handler params unchanged alongside scope', async () => { + it('merges data attributes and event.detail with detail winning', async () => { let captured; const fixture = await mountTemplate({ target, events: { - 'click .btn'({ scope, data, value, target: clicked, event }) { - captured = { scope, data, value, clicked, event }; + 'custom .btn'({ data, value, target: clicked, event }) { + captured = { data, value, clicked, event }; }, }, }); - fixture.renderRoot.innerHTML = ''; + fixture.renderRoot.innerHTML = ''; try { const btn = fixture.renderRoot.querySelector('.btn'); - clickOn(btn); - expect(captured.scope).toEqual({}); - expect(captured.data).toEqual({ id: 7 }); + btn.dispatchEvent(new CustomEvent('custom', { + bubbles: true, + composed: true, + detail: { kind: 'detail' }, + })); + expect(captured.data).toEqual({ id: 7, kind: 'detail' }); expect(captured.value).toBe('hello'); expect(captured.clicked).toBe(btn); - expect(captured.event).toBeInstanceOf(MouseEvent); + expect(captured.event).toBeInstanceOf(CustomEvent); } finally { fixture.cleanup(); @@ -163,13 +125,13 @@ RENDER_TARGETS.forEach(({ name, target }) => { }); }); -describe('event scope param — non-delegated dialects', () => { +describe('event data param — non-delegated dialects', () => { it('resolves to an empty object for naked-selector host events', async () => { let captured; const fixture = await mountTemplate({ events: { - 'click'({ scope }) { - captured = scope; + 'click'({ data }) { + captured = data; }, }, }); @@ -186,8 +148,8 @@ describe('event scope param — non-delegated dialects', () => { let captured; const fixture = await mountTemplate({ events: { - 'global hashchange window'({ scope }) { - captured = scope; + 'global hashchange window'({ data }) { + captured = data; }, }, }); From 66e90ac0942d5141b442292018f0c7434b0ec342 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 17:04:07 -0400 Subject: [PATCH 10/16] Docs: Document template scope in event data --- docs/src/content/examples/event-scope.mdx | 4 +-- .../framework/events/event-scope/component.js | 12 ++++---- .../pages/docs/guides/components/events.mdx | 29 ++++++++++++++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/src/content/examples/event-scope.mdx b/docs/src/content/examples/event-scope.mdx index b52b5742d..b9810bf32 100644 --- a/docs/src/content/examples/event-scope.mdx +++ b/docs/src/content/examples/event-scope.mdx @@ -4,6 +4,6 @@ exampleType: 'component' category: 'Framework' subcategory: 'Events' tags: ['component', 'events', 'scope', 'each', 'getting-started'] -tip: '`scope` resolves the template vars acting at the event target — each item vars, subtemplate args, snippet args — so list handlers never round-trip identity through `data-*` attributes' -description: Reading each item vars from event handlers with scope +tip: '`data` includes the template vars acting at the event target — each item vars, subtemplate args, snippet args — so list handlers never round-trip identity through `data-*` attributes' +description: Reading each item vars from event data --- diff --git a/docs/src/examples/framework/events/event-scope/component.js b/docs/src/examples/framework/events/event-scope/component.js index 8c83073ac..47e972cbc 100644 --- a/docs/src/examples/framework/events/event-scope/component.js +++ b/docs/src/examples/framework/events/event-scope/component.js @@ -23,13 +23,13 @@ const createComponent = ({ state }) => ({ const events = { - // scope holds the template vars at the clicked row - // i.e. {#each song in songs} provides scope.song and scope.index - 'click .play'({ state, scope }) { - state.nowPlaying.set(scope.song.id); + // data includes the template vars at the clicked row + // i.e. {#each song in songs} provides data.song and data.index + 'click .play'({ state, data }) { + state.nowPlaying.set(data.song.id); }, - 'click .remove'({ state, scope }) { - state.songs.removeItem(scope.song.id); + 'click .remove'({ state, data }) { + state.songs.removeItem(data.song.id); }, }; diff --git a/docs/src/pages/docs/guides/components/events.mdx b/docs/src/pages/docs/guides/components/events.mdx index 3fe630ee9..0c534a4c5 100644 --- a/docs/src/pages/docs/guides/components/events.mdx +++ b/docs/src/pages/docs/guides/components/events.mdx @@ -161,7 +161,7 @@ In addition to the [standard arguments](/docs/guides/components/lifecycle) that | el | the component's host element | | target | the dom element that dispatched the event | | event | the native event object | -| data | event.detail + data attributes on the dispatching element | +| data | data attributes, template scope at the target, and event.detail — merged in that order, later sources win | | value | value of the dispatching element (form fields, custom event detail) | | isDeep | the event came from inside another web component's shadow tree | @@ -192,6 +192,33 @@ const events = { In many cases you want to access data particular to an event. Event handlers can use `data` to achieve this. +`data` merges three sources: data attributes on the dispatching element, the template scope at that element, and `event.detail`. When keys overlap, later sources win. + +### Template Scope + +Template vars acting at the dispatching element — [each](/docs/guides/templates/loops) item vars, subtemplate args, snippet args — are available directly on `data`. List handlers can read row identity from the each scope without round-tripping it through data attributes. + +```html +
      + {#each song in songs} +
    • + {song.title} + Play +
    • + {/each} +
    +``` + +```javascript +const events = { + 'click .play'({ self, data }) { + self.play(data.song.id); + } +}; +``` + + + ### HTML Metadata HTML metadata can be added using data attributes and will be available directly from the data object From 68375954dabe479b887c09a98fbea615e2067a61 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 17:28:30 -0400 Subject: [PATCH 11/16] Bug: Keep attribute precedence in event data --- ai/skills/contributing/calibration.md | 122 ++++++++++++++++++ .../{event-scope.mdx => list-event-data.mdx} | 4 +- .../component.css | 0 .../component.html | 0 .../component.js | 0 .../pages/docs/guides/components/events.mdx | 4 +- .../test/browser/event-scope-blocks.test.js | 13 +- packages/templating/src/template.js | 26 ++-- .../test/browser/event-data.test.js | 12 +- 9 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 ai/skills/contributing/calibration.md rename docs/src/content/examples/{event-scope.mdx => list-event-data.mdx} (78%) rename docs/src/examples/framework/events/{event-scope => list-event-data}/component.css (100%) rename docs/src/examples/framework/events/{event-scope => list-event-data}/component.html (100%) rename docs/src/examples/framework/events/{event-scope => list-event-data}/component.js (100%) diff --git a/ai/skills/contributing/calibration.md b/ai/skills/contributing/calibration.md new file mode 100644 index 000000000..c9b86aa10 --- /dev/null +++ b/ai/skills/contributing/calibration.md @@ -0,0 +1,122 @@ +--- +title: Contributor Calibration +description: The objective functions this codebase optimizes and the epistemic rules for claims made while contributing — what counts as evidence, what to optimize, and how agents and human contributors keep each other calibrated. +keywords: [calibration, objective function, evidence, hypothesis, verification, benchmarks, failing test, bisect, provenance, collaboration, epistemics] +audience: contributing +skill: calibration +type: skill +--- + +# Contributor Calibration + +> **Skill:** `calibration` +> **Purpose:** What this codebase optimizes, what counts as evidence for a claim, and how contributors — human and agent — keep each other calibrated + +**Golden rule: calibration is two skills — choosing the right objective and knowing when a claim is actually established. The expensive mistakes in this repo's history are competent optimizations of the wrong objective, and confident claims that were never tested.** + +--- + +## The Objective Functions + +These are what the project optimizes, in the order conflicts resolve. When two of them collide, the one higher in this table wins. + +| Objective | What it means in practice | +|---|---| +| User-felt behavior | A change users can feel beats any internal metric. A perf win below human perception that costs debugging ergonomics is a net loss. | +| Externally visible benchmarks | The js-framework-benchmark numbers are the public record. A change that wins internal benches and loses krausest gets reverted — internal metrics do not compensate. | +| Shipped bytes | Most compiles happen in the browser via CDN, so runtime-package size is user-facing performance. Measure minified + brotli on any runtime-shipped change without being asked — CI reports timing only, so byte costs are silent by default. | +| First-contact ergonomics | Design APIs for the first five seconds of real use. The most common act on any value a handler receives is logging it — a design that survives spec review but fails `console.log` inspection is the wrong design. | +| Best API over compatibility | Pre-1.0, propose the cleanest design first and verify actual blast radius. Do not preserve dual formats to protect output that was buggy — wipe and reset beats compatibility cruft. | +| Smallest structure the constraint demands | Size a change from the constraint and the existing seam, not from pattern completion. When the implementation estimate far exceeds the conceptual delta, that gap is the signal you are building symmetry, not solving the problem. | + +```text +❌ "This refactor improves our internal each-mount bench 9%" (krausest regressed 4%) +✅ Revert. The externally visible number is the objective. + +❌ A second helper, an export, and a registry entry "for consistency" with a sibling module +✅ The three lines the constraint actually demands, at the existing seam +``` + +Description length follows the same logic: artifacts scale with the diff, not with how interesting the work was. A two-line fix gets one sentence and a risk score. The diff is the evidence — the text gives orientation, in words a competent engineer with zero knowledge of this codebase's internal vocabulary can follow. Name code regions by their job ("the code that builds the arguments every event handler receives"), never by terms coined during the work. + +--- + +## Epistemic Classes + +Every claim a contributor makes belongs to a class, and the class determines what you may say about it. + +| You have | You may claim | You may not claim | +|---|---|---| +| A source-reading trace, however careful | A hypothesis | "Verified", "confirmed", a bug report | +| A failing test that reproduces it | A fact, scoped to what the test shows | Anything broader than the test's shape | +| A bench verdict in the confident bucket | A real cost, located somewhere reachable | That you know *where* the cost is paid | +| A passing suite | The checked surface holds | That unstated invariants hold | +| "Nothing I tried could disprove X" | Nothing | "The evidence points to X" | + +The last row is the elimination trap. A conclusion that survives because it cannot be falsified — V8 internals, GC, harness noise — carries no evidentiary weight. When reading cannot localize a cost, the move is mechanical, not clever: bisect. Revert only the suspect on the same branch and re-measure. PR #229's "profile-only V8 effect" was three `.bind()` calls, found in one revert cycle after extended reading had parked it in fog. + +### Rules that follow + +**Write the failing test first, watch it fail, then fix.** A hypothesis-class finding becomes a fact at the moment a test reproduces it, and not before. The failing run also pins the exact repro, so the fix cannot quietly solve an adjacent problem and leave the original breathing. No fix ships without its repro test. + +**One failing expectation is evidence about your test first.** Generalizing from a single failing assertion to "the framework doesn't support X" is the most common overclaim shape. Before claiming a defect, vary the shape: the same behavior in a different structural position usually splits "broken" into "broken in exactly this shape," which is a different and more useful fact. + +```text +❌ "Subtemplate events don't fire" (one failing expect, published as a finding) +✅ Three variants: top-level passes, wrapped-in-each passes, bare-in-each fails. + The claim is now "events fail when the invocation is the top level of + each-item content" — narrow, mechanical, and it survived review. +``` + +**Untested is not the same as working.** The complementary failure: a behavior nobody wrote a test for can be silently broken for months while every example happens to avoid the failing shape. Rewrites are the classic source — they preserve the checked surface (tests, examples) and shed invariants nobody wrote down. When a behavior matters, the invariant belongs in a test, not in someone's head. + +**Date a bug before attributing it.** `git log -S` on the construct, or an engine-forked test when two implementations exist, tells you which change introduced a defect. Provenance narrows the mechanism and keeps the report factual — "introduced in the X refactor" is a different and better claim than "this system is broken." + +**Know what a measurement actually compared.** CI builds benchmarks from the main branch in both arms, so a PR that only changes bench files produces a null comparison by design — its per-metric verdicts measured nothing about the change. The anti-gaming property is deliberate: an agent optimizing perf must not be able to improve numbers by editing the measuring stick. Read every report by first asking what was on each side. + +**Confident measurements beat your mechanism model.** The harness has been validated against null changes — behavior-preserving PRs produce zero confident verdicts. When a confident result contradicts your model of the code, the model is wrong. Update cleanly and without ceremony: no "I'm not an expert but" disclaimers on the way in, no self-flagellation on the way out. + +**A wall is a hypothesis.** "Not possible", "not permitted", "the tool can't" — when evidence contradicts a wall, investigate the wall. The same applies to giving up: "structural problem" and "diminishing returns" are signals to bring fresh eyes, not conclusions. Repeatedly in this repo's history, the push past a graceful-completion summary is where the actual progress was. + +--- + +## Working With Each Other + +Contribution here is collaborative between humans and agents, and the calibration burden is mutual. + +**Surface findings and propose the fix shape before editing.** Even small changes, even when working in parallel. A finding presented with a proposed shape invites correction at the cheapest possible moment — before any code exists to be attached to. + +**Push back with evidence.** Agreement is not a contribution. When you hold evidence that a suggestion is wrong — a trace, a measurement, a prior PR that tried it — present it directly. The counterpart rule: when someone pushes back on your claim and they hold contradictory experience, treat their pushback as data about your claim's scope, not as resistance to overcome. Both directions of this loop have caught real bugs. + +**Fresh eyes need pruned context.** A second opinion contaminated by your solution momentum explores your neighborhood and confirms your direction. When delegating for a genuinely independent take, transfer the problem knowledge — constraints, symptoms, invariants — and structurally withhold every approach you have tried. The `fresh-take` skill is the procedure. Independent reconvergence is strong evidence; a fresh derivation that lands on your same wall means the wall is probably real. + +**Report state, not effort.** Outcomes faithfully: failing tests with their output, skipped steps named as skipped, done things stated plainly without hedging. Process narration — "verified", "carefully checked", "ran the full suite" — performs thoroughness without adding information. The diff, the test counts, and the measurements are the report. + +**Prior attempts are searchable history.** Before proposing an optimization or design, check whether a closed PR already tried it and what the measurement said. An idea that was tried and measured flat needs a new argument, not a re-proposal. Equally: record *why* something was closed, because the next contributor's good idea is often the last contributor's closed PR. + +--- + +## Quick Reference + +```text +Conflict resolution: user-felt > krausest > bytes > ergonomics > clean API > less structure +Claim classes: reading = hypothesis · failing test = fact · confident bench = real cost + "couldn't disprove" = nothing +Before claiming a bug: vary the shape, date it with git log -S, then write the failing test +Before optimizing: check closed PRs, know what the bench compares, measure bytes +Before editing: surface the finding, propose the shape, wait for sign-off +When stuck: bisect before theorizing · fresh-take before giving up +When measured: the data wins, update cleanly +``` + +--- + +## Related Skills + +| Skill | Command | Use when... | +|-------|---------|-------------| +| **Fresh Take** | `fresh-take` | Delegating for an uncontaminated second opinion | +| **Improve Performance** | `improve-performance` | The full audit, trace, fix, measure cycle | +| **Reading Bench Reports** | `read-bench-report` | Interpreting CI bench verdicts on a PR | +| **Authoring Pull Requests** | `author-pull-requests` | Titles and descriptions that scale with the diff | +| **Agent Lessons** | `agent-lessons` | Distilled traps from previous agent sessions | diff --git a/docs/src/content/examples/event-scope.mdx b/docs/src/content/examples/list-event-data.mdx similarity index 78% rename from docs/src/content/examples/event-scope.mdx rename to docs/src/content/examples/list-event-data.mdx index b9810bf32..1ed4c22c3 100644 --- a/docs/src/content/examples/event-scope.mdx +++ b/docs/src/content/examples/list-event-data.mdx @@ -1,9 +1,9 @@ --- -title: 'Event Scope' +title: 'List Event Data' exampleType: 'component' category: 'Framework' subcategory: 'Events' -tags: ['component', 'events', 'scope', 'each', 'getting-started'] +tags: ['component', 'events', 'data', 'each', 'getting-started'] tip: '`data` includes the template vars acting at the event target — each item vars, subtemplate args, snippet args — so list handlers never round-trip identity through `data-*` attributes' description: Reading each item vars from event data --- diff --git a/docs/src/examples/framework/events/event-scope/component.css b/docs/src/examples/framework/events/list-event-data/component.css similarity index 100% rename from docs/src/examples/framework/events/event-scope/component.css rename to docs/src/examples/framework/events/list-event-data/component.css diff --git a/docs/src/examples/framework/events/event-scope/component.html b/docs/src/examples/framework/events/list-event-data/component.html similarity index 100% rename from docs/src/examples/framework/events/event-scope/component.html rename to docs/src/examples/framework/events/list-event-data/component.html diff --git a/docs/src/examples/framework/events/event-scope/component.js b/docs/src/examples/framework/events/list-event-data/component.js similarity index 100% rename from docs/src/examples/framework/events/event-scope/component.js rename to docs/src/examples/framework/events/list-event-data/component.js diff --git a/docs/src/pages/docs/guides/components/events.mdx b/docs/src/pages/docs/guides/components/events.mdx index 0c534a4c5..c0527ee97 100644 --- a/docs/src/pages/docs/guides/components/events.mdx +++ b/docs/src/pages/docs/guides/components/events.mdx @@ -161,7 +161,7 @@ In addition to the [standard arguments](/docs/guides/components/lifecycle) that | el | the component's host element | | target | the dom element that dispatched the event | | event | the native event object | -| data | data attributes, template scope at the target, and event.detail — merged in that order, later sources win | +| data | template scope at the target, data attributes, and event.detail — merged in that order, later sources win | | value | value of the dispatching element (form fields, custom event detail) | | isDeep | the event came from inside another web component's shadow tree | @@ -192,7 +192,7 @@ const events = { In many cases you want to access data particular to an event. Event handlers can use `data` to achieve this. -`data` merges three sources: data attributes on the dispatching element, the template scope at that element, and `event.detail`. When keys overlap, later sources win. +`data` merges three sources: the template scope at the dispatching element, its data attributes, and `event.detail`. When keys overlap, later sources win. ### Template Scope diff --git a/packages/renderer/test/browser/event-scope-blocks.test.js b/packages/renderer/test/browser/event-scope-blocks.test.js index 038e66d9a..1fa15666e 100644 --- a/packages/renderer/test/browser/event-scope-blocks.test.js +++ b/packages/renderer/test/browser/event-scope-blocks.test.js @@ -1,9 +1,9 @@ // Block scope layers in the event-handler `data` param: each item vars, // subtemplate args, snippet args, async vars, resolved from the event // target's position via the boundary-marker bracket scan and merged into -// data above data-* attributes and below event.detail. Layer resolution -// is a native-engine contract — lit contributes no scope keys, asserted -// at the bottom of the engine loop. +// data below data-* attributes, with event.detail above all. Layer +// resolution is a native-engine contract — lit contributes no scope keys, +// asserted at the bottom of the engine loop. import { defineComponent } from '@semantic-ui/component'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -189,7 +189,7 @@ RENDERING_ENGINES.forEach((engine) => { expect(captured).toEqual({}); }); - it.skipIf(isLit)('merges scope over data attributes and detail over scope', async () => { + it.skipIf(isLit)('merges template vars below data attributes and detail above all', async () => { const tag = uniqueTag(); let captured; defineComponent({ @@ -209,8 +209,11 @@ RENDERING_ENGINES.forEach((engine) => { const el = await mount(tag); const row = el.shadowRoot.querySelector('.row'); clickOn(row); - expect(captured.id).toBe('from-scope'); + // an explicit attribute keeps answering while it exists — delete + // it and the template layer takes over through the same read + expect(captured.id).toBe('from-attr'); expect(captured.extra).toBe('attr-only'); + expect(captured.name).toBe('Alpha'); row.dispatchEvent( new CustomEvent('click', { diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index d3626c238..985820510 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -787,15 +787,7 @@ export const Template = class Template { // peeked, nothing subscribes. getEventData(target, event) { return nonreactive(() => { - // dataset is always stringified for atts, we want native values - const eventData = mapObject({ ...target?.dataset }, (stringValue) => { - try { - return JSON.parse(stringValue); - } - catch (e) { - return stringValue; - } - }); + const data = {}; const layers = this.renderer?.resolveScopeLayers?.(target, { // same live-boundary rule as isNodeInTemplate — stored parentNode // can be a dead build fragment for subtemplates attached mid-render @@ -807,11 +799,23 @@ export const Template = class Template { const layer = layers[i]; for (const key in layer) { const value = layer[key]; - eventData[key] = (value instanceof Signal) ? value.peek() : value; + data[key] = (value instanceof Signal) ? value.peek() : value; } } } - return Object.assign(eventData, event?.detail); + // dataset is always stringified for attrs, we want native values. + // explicit attributes and detail shadow template vars — a handler + // keeps its value while a data-* attr exists and falls through to + // the template layer when the attr is removed + const attrData = mapObject({ ...target?.dataset }, (stringValue) => { + try { + return JSON.parse(stringValue); + } + catch (e) { + return stringValue; + } + }); + return Object.assign(data, attrData, event?.detail); }); } diff --git a/packages/templating/test/browser/event-data.test.js b/packages/templating/test/browser/event-data.test.js index 19d613d16..6f7d0e1af 100644 --- a/packages/templating/test/browser/event-data.test.js +++ b/packages/templating/test/browser/event-data.test.js @@ -107,11 +107,13 @@ RENDER_TARGETS.forEach(({ name, target }) => { fixture.renderRoot.innerHTML = ''; try { const btn = fixture.renderRoot.querySelector('.btn'); - btn.dispatchEvent(new CustomEvent('custom', { - bubbles: true, - composed: true, - detail: { kind: 'detail' }, - })); + btn.dispatchEvent( + new CustomEvent('custom', { + bubbles: true, + composed: true, + detail: { kind: 'detail' }, + }), + ); expect(captured.data).toEqual({ id: 7, kind: 'detail' }); expect(captured.value).toBe('hello'); expect(captured.clicked).toBe(btn); From 071b19b6be27ed2f9ba562343a13c4f8b5920269 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 17:38:42 -0400 Subject: [PATCH 12/16] Bug: Point events guide at renamed example --- docs/src/pages/docs/guides/components/events.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/docs/guides/components/events.mdx b/docs/src/pages/docs/guides/components/events.mdx index c0527ee97..9446d58fe 100644 --- a/docs/src/pages/docs/guides/components/events.mdx +++ b/docs/src/pages/docs/guides/components/events.mdx @@ -217,7 +217,7 @@ const events = { }; ``` - + ### HTML Metadata From c649b459797e5726622d1b25ac0ef1ff401467c4 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 17:51:09 -0400 Subject: [PATCH 13/16] Refactor: Drop data attribute round trips in examples --- .../examples/component/async-search/component.html | 2 +- .../examples/component/color-palette/component.html | 2 -- .../examples/component/color-palette/component.js | 5 ++--- .../component/color-picker/color-picker.html | 2 +- .../examples/component/context-menu/component.html | 1 - .../src/examples/component/context-menu/component.js | 2 +- .../component/emoji-reactions/component.html | 2 +- .../examples/component/emoji-reactions/component.js | 2 +- .../examples/component/form-builder/component.html | 12 ++++++------ .../src/examples/component/form-builder/component.js | 4 ++-- .../examples/component/star-rating/component.html | 2 +- docs/src/examples/component/tabs/component.html | 2 +- docs/src/examples/component/todo-list/todo-item.html | 8 ++++---- .../framework/complex/accordion/accordion.html | 2 +- .../keys/advanced-keybinding/component.html | 2 +- .../examples/framework/theme-preview/component.html | 1 - .../examples/framework/theme-preview/component.js | 2 +- .../reactivity/advanced/birthday/component.html | 2 +- .../reactivity/advanced/birthday/component.js | 2 +- .../examples/templates/async-advanced/component.html | 2 +- examples/src/advanced-keybinding/component.html | 2 +- examples/src/advanced-keybinding/page.html | 2 +- examples/src/async-search/component.html | 2 +- examples/src/async-search/page.html | 2 +- examples/src/context-menu/component.html | 1 - examples/src/context-menu/component.js | 2 +- examples/src/context-menu/page.html | 3 +-- examples/src/emoji-reactions/component.html | 2 +- examples/src/emoji-reactions/component.js | 2 +- examples/src/emoji-reactions/page.html | 4 ++-- examples/src/todo-list/page.html | 8 ++++---- 31 files changed, 41 insertions(+), 48 deletions(-) diff --git a/docs/src/examples/component/async-search/component.html b/docs/src/examples/component/async-search/component.html index 755cc5a45..ecb68b568 100644 --- a/docs/src/examples/component/async-search/component.html +++ b/docs/src/examples/component/async-search/component.html @@ -5,7 +5,7 @@ {#async getResults searchTerm as searchResults} {#if hasAny searchResults} {#each searchResults} -
    +
    {title}
    {/each} diff --git a/docs/src/examples/component/color-palette/component.html b/docs/src/examples/component/color-palette/component.html index 1d31aaf05..a486dd018 100644 --- a/docs/src/examples/component/color-palette/component.html +++ b/docs/src/examples/component/color-palette/component.html @@ -8,8 +8,6 @@

    {displayName}

    {shade.name}
    diff --git a/docs/src/examples/component/color-palette/component.js b/docs/src/examples/component/color-palette/component.js index 48f8b364c..8c9413df9 100644 --- a/docs/src/examples/component/color-palette/component.js +++ b/docs/src/examples/component/color-palette/component.js @@ -96,9 +96,8 @@ const createComponent = ({ self, state, settings, $, isServer, dispatchEvent, ti const events = { 'click .swatch'({ self, settings, data, event }) { - const { color, shade } = data; - if (settings.showCopy && color && shade) { - self.copyColor(color, shade); + if (settings.showCopy && data.name && data.shade) { + self.copyColor(data.name, data.shade.name); } }, }; diff --git a/docs/src/examples/component/color-picker/color-picker.html b/docs/src/examples/component/color-picker/color-picker.html index 583db6cf8..98e0ac283 100644 --- a/docs/src/examples/component/color-picker/color-picker.html +++ b/docs/src/examples/component/color-picker/color-picker.html @@ -2,7 +2,7 @@
    {#each color in colorOptions} -
    +
    {/each}
    diff --git a/docs/src/examples/component/context-menu/component.html b/docs/src/examples/component/context-menu/component.html index 282453c75..ad6749401 100755 --- a/docs/src/examples/component/context-menu/component.html +++ b/docs/src/examples/component/context-menu/component.html @@ -9,7 +9,6 @@ {else} {#if editing} - + {/if} diff --git a/docs/src/examples/framework/complex/accordion/accordion.html b/docs/src/examples/framework/complex/accordion/accordion.html index 37ce51333..9bccc5983 100644 --- a/docs/src/examples/framework/complex/accordion/accordion.html +++ b/docs/src/examples/framework/complex/accordion/accordion.html @@ -1,7 +1,7 @@
    {#each sections}
    -
    +
    {title} {#if isExpanded index} diff --git a/docs/src/examples/framework/keys/advanced-keybinding/component.html b/docs/src/examples/framework/keys/advanced-keybinding/component.html index 2cbb71cbe..e831897fb 100644 --- a/docs/src/examples/framework/keys/advanced-keybinding/component.html +++ b/docs/src/examples/framework/keys/advanced-keybinding/component.html @@ -9,7 +9,7 @@

    {titleCase noun} Choices

    {#if hasAny results} {#each result in results} -
    {result}
    +
    {result}
    {/each} {else if searchTerm}
    diff --git a/docs/src/examples/framework/theme-preview/component.html b/docs/src/examples/framework/theme-preview/component.html index 29cb611bf..cfaceccfc 100644 --- a/docs/src/examples/framework/theme-preview/component.html +++ b/docs/src/examples/framework/theme-preview/component.html @@ -11,7 +11,6 @@ {#each preset in presets}
    {preset.name}
    diff --git a/docs/src/examples/framework/theme-preview/component.js b/docs/src/examples/framework/theme-preview/component.js index 7b82b095a..026f4823e 100644 --- a/docs/src/examples/framework/theme-preview/component.js +++ b/docs/src/examples/framework/theme-preview/component.js @@ -82,7 +82,7 @@ const onRendered = ({ self, settings }) => { const events = { 'click .preset'({ self, settings, data }) { - const preset = settings.presets[data.index]; + const preset = data.preset; self.applyTheme(preset); }, diff --git a/docs/src/examples/reactivity/advanced/birthday/component.html b/docs/src/examples/reactivity/advanced/birthday/component.html index 376c61235..04858b60a 100644 --- a/docs/src/examples/reactivity/advanced/birthday/component.html +++ b/docs/src/examples/reactivity/advanced/birthday/component.html @@ -4,7 +4,7 @@

    Birthday Calendar

    {#each friend in birthdayCalendar}
  • {friend.name} - {friend.birthday} - + Make it Today
  • diff --git a/docs/src/examples/reactivity/advanced/birthday/component.js b/docs/src/examples/reactivity/advanced/birthday/component.js index 1462b401e..b49ee6b16 100644 --- a/docs/src/examples/reactivity/advanced/birthday/component.js +++ b/docs/src/examples/reactivity/advanced/birthday/component.js @@ -45,7 +45,7 @@ const events = { state.today.set(self.getDisplayDate(newDay)); }, 'click a.birthday'({ state, data }) { - state.today.set(data.birthday); + state.today.set(data.friend.birthday); }, }; diff --git a/docs/src/examples/templates/async-advanced/component.html b/docs/src/examples/templates/async-advanced/component.html index 228045b1a..b90d6c062 100644 --- a/docs/src/examples/templates/async-advanced/component.html +++ b/docs/src/examples/templates/async-advanced/component.html @@ -5,7 +5,7 @@ {#async getResults searchTerm as searchResults} {#if hasAny searchResults} {#each searchResults} -
    +
    {title}
    {/each} diff --git a/examples/src/advanced-keybinding/component.html b/examples/src/advanced-keybinding/component.html index 2cbb71cbe..e831897fb 100644 --- a/examples/src/advanced-keybinding/component.html +++ b/examples/src/advanced-keybinding/component.html @@ -9,7 +9,7 @@

    {titleCase noun} Choices

    {#if hasAny results} {#each result in results} -
    {result}
    +
    {result}
    {/each} {else if searchTerm}
    diff --git a/examples/src/advanced-keybinding/page.html b/examples/src/advanced-keybinding/page.html index f834034fc..f09840f7b 100644 --- a/examples/src/advanced-keybinding/page.html +++ b/examples/src/advanced-keybinding/page.html @@ -539,7 +539,7 @@

    Static and runtime keybindings

    <div class="results"> {#if hasAny results} {#each result in results} - <div class="{activeIf is index selectedIndex}result" data-index="{index}">{result}</div> + <div class="{activeIf is index selectedIndex}result">{result}</div> {/each} {else if searchTerm} <div class="empty"> diff --git a/examples/src/async-search/component.html b/examples/src/async-search/component.html index 755cc5a45..ecb68b568 100644 --- a/examples/src/async-search/component.html +++ b/examples/src/async-search/component.html @@ -5,7 +5,7 @@ {#async getResults searchTerm as searchResults} {#if hasAny searchResults} {#each searchResults} -
    +
    {title}
    {/each} diff --git a/examples/src/async-search/page.html b/examples/src/async-search/page.html index 52ed091ef..072cfa3a6 100755 --- a/examples/src/async-search/page.html +++ b/examples/src/async-search/page.html @@ -542,7 +542,7 @@

    Async blocks

    {#async getResults searchTerm as searchResults} {#if hasAny searchResults} {#each searchResults} - <div class="{activeIf is index selectedIndex} result" data-id="{id}" data-title="{title}"> + <div class="{activeIf is index selectedIndex} result"> {title} </div> {/each} diff --git a/examples/src/context-menu/component.html b/examples/src/context-menu/component.html index 282453c75..ad6749401 100755 --- a/examples/src/context-menu/component.html +++ b/examples/src/context-menu/component.html @@ -9,7 +9,6 @@ {else}