fix(state): 修复动态组件上下文与模板插槽(by AI)

This commit is contained in:
Star 2026-07-12 02:01:07 +08:00
parent eb0145ea85
commit ea96c4ef22
8 changed files with 91 additions and 13 deletions

16
dist/state.js vendored
View File

@ -247,9 +247,17 @@
Array.from(sourceNodes).forEach((child) => target.appendChild(child)); Array.from(sourceNodes).forEach((child) => target.appendChild(child));
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
} }
const _findSlotPlaceholders = (root) => {
const placeholders = [...root.querySelectorAll("[slot-id]")];
root.querySelectorAll("template").forEach((template) => {
placeholders.push(..._findSlotPlaceholders(template.content));
});
return placeholders;
};
function _makeComponent(name, node, scanObj, exists = {}) { function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return; if (exists[name]) return;
exists[name] = true; exists[name] = true;
const parentThis = scanObj.thisObj;
if (scanObj.thisObj) { if (scanObj.thisObj) {
Array.from(node.attributes).forEach((attr) => { Array.from(node.attributes).forEach((attr) => {
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) { if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
@ -273,7 +281,7 @@
if (tplnode.childNodes.length) { if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0]; const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists); if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
$$(node, "[slot-id]").forEach((placeholder) => { _findSlotPlaceholders(node).forEach((placeholder) => {
const slotName = placeholder.getAttribute("slot-id"); const slotName = placeholder.getAttribute("slot-id");
if (slots[slotName]) { if (slots[slotName]) {
placeholder.removeAttribute("slot-id"); placeholder.removeAttribute("slot-id");
@ -284,6 +292,8 @@
} }
} }
if (componentFunc) componentFunc(node); if (componentFunc) componentFunc(node);
node._thisObj = node;
if (parentThis && parentThis !== node) node._thisObj.parent = parentThis;
} }
let _translator = (text, args) => { let _translator = (text, args) => {
if (!text || typeof text !== "string") return text; if (!text || typeof text !== "string") return text;
@ -540,7 +550,7 @@
if (realAttrName === "bind") { if (realAttrName === "bind") {
const isTextInput = ["INPUT", "TEXTAREA"].includes(node.tagName) && ["textarea", "text", "password", "email", "number", "search", "url", "tel"].includes(node.type || "text") || node.isContentEditable; const isTextInput = ["INPUT", "TEXTAREA"].includes(node.tagName) && ["textarea", "text", "password", "email", "number", "search", "url", "tel"].includes(node.type || "text") || node.isContentEditable;
node.addEventListener(isTextInput ? "input" : "change", (e) => { node.addEventListener(isTextInput ? "input" : "change", (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail; let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : node.type === "file" ? e.target.files : e.target.value ?? e.detail;
_setNoWriteBack(node); _setNoWriteBack(node);
setDisableRunCodeError(true); setDisableRunCodeError(true);
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {}); if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
@ -636,7 +646,7 @@
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars); if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
_parseNode(node, { ...scanObj }); _parseNode(node, { ...scanObj });
const nodes = [...node.childNodes || []]; const nodes = [...node.childNodes || []];
nodes.forEach((child) => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } })); nodes.forEach((child) => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
}; };
const _unbindTree = (node) => { const _unbindTree = (node) => {
if (node.nodeType !== 1) return; if (node.nodeType !== 1) return;

2
dist/state.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "@apigo.cc/state", "name": "@apigo.cc/state",
"version": "1.0.23", "version": "1.0.24",
"type": "module", "type": "module",
"main": "dist/state.js", "main": "dist/state.js",
"module": "dist/state.js", "module": "dist/state.js",
@ -9,6 +9,7 @@
], ],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"prebuild": "node ../.profile/scripts/sync-readme-versions.mjs",
"build": "vite build", "build": "vite build",
"test": "playwright test", "test": "playwright test",
"pub": "node scripts/publish.js" "pub": "node scripts/publish.js"

View File

@ -4,11 +4,11 @@ export default defineConfig({
testDir: './test', testDir: './test',
testMatch: '**/*.spec.js', testMatch: '**/*.spec.js',
use: { use: {
baseURL: 'http://127.0.0.1:8081', baseURL: 'http://127.0.0.1:5173',
}, },
webServer: { webServer: {
command: 'npx vite --port 8081 --host 127.0.0.1', command: 'npx vite --port 5173 --host 127.0.0.1',
url: 'http://127.0.0.1:8081/test/index.html', url: 'http://127.0.0.1:5173/test/index.html',
timeout: 180000, timeout: 180000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View File

@ -70,9 +70,21 @@ export function _mergeNode(from, to, scanObj, exists = {}) {
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
} }
// Slots may be declared inside a rendering template (for example `$each`).
// `querySelectorAll` does not cross a <template>'s content boundary, so walk
// those fragments explicitly while leaving ordinary DOM traversal unchanged.
const _findSlotPlaceholders = root => {
const placeholders = [...root.querySelectorAll('[slot-id]')]
root.querySelectorAll('template').forEach(template => {
placeholders.push(..._findSlotPlaceholders(template.content))
})
return placeholders
}
export function _makeComponent(name, node, scanObj, exists = {}) { export function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return; if (exists[name]) return;
exists[name] = true; exists[name] = true;
const parentThis = scanObj.thisObj;
if (scanObj.thisObj) { if (scanObj.thisObj) {
Array.from(node.attributes).forEach(attr => { Array.from(node.attributes).forEach(attr => {
if ((attr.name.startsWith('$') || attr.name.startsWith('st-')) && attr.value.includes('this.')) { if ((attr.name.startsWith('$') || attr.name.startsWith('st-')) && attr.value.includes('this.')) {
@ -96,7 +108,7 @@ export function _makeComponent(name, node, scanObj, exists = {}) {
if (tplnode.childNodes.length) { if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0]; const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists); if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
$$(node, '[slot-id]').forEach(placeholder => { _findSlotPlaceholders(node).forEach(placeholder => {
const slotName = placeholder.getAttribute('slot-id'); const slotName = placeholder.getAttribute('slot-id');
if (slots[slotName]) { if (slots[slotName]) {
placeholder.removeAttribute('slot-id'); placeholder.removeAttribute('slot-id');
@ -107,6 +119,10 @@ export function _makeComponent(name, node, scanObj, exists = {}) {
} }
} }
if (componentFunc) componentFunc(node); if (componentFunc) componentFunc(node);
// A component always owns `this`; the creating component is available as
// `this.parent`. Dynamic nodes may have inherited their creator's context.
node._thisObj = node;
if (parentThis && parentThis !== node) node._thisObj.parent = parentThis;
} }
// --- DOM Engine Logic --- // --- DOM Engine Logic ---
@ -368,7 +384,7 @@ export function _parseNode(node, scanObj) {
if (realAttrName === 'bind') { if (realAttrName === 'bind') {
const isTextInput = (['INPUT', 'TEXTAREA'].includes(node.tagName) && ['textarea', 'text', 'password', 'email', 'number', 'search', 'url', 'tel'].includes(node.type || 'text')) || node.isContentEditable; const isTextInput = (['INPUT', 'TEXTAREA'].includes(node.tagName) && ['textarea', 'text', 'password', 'email', 'number', 'search', 'url', 'tel'].includes(node.type || 'text')) || node.isContentEditable;
node.addEventListener(isTextInput ? 'input' : 'change', (e) => { node.addEventListener(isTextInput ? 'input' : 'change', (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : e.target.files || e.target.value || e.detail); let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : (node.type === 'file' ? e.target.files : (e.target.value ?? e.detail)));
_setNoWriteBack(node); setDisableRunCodeError(true); _setNoWriteBack(node); setDisableRunCodeError(true);
if (node.type === 'checkbox' && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {}); if (node.type === 'checkbox' && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {}); else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
@ -463,7 +479,9 @@ export const _scanTree = (node, scanObj = {}) => {
_parseNode(node, { ...scanObj }); _parseNode(node, { ...scanObj });
const nodes = [...(node.childNodes || [])]; const nodes = [...(node.childNodes || [])];
nodes.forEach(child => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } })); // A component's template belongs to that component, not to the context that created it.
// This keeps `this` in nested component templates bound to the nested component.
nodes.forEach(child => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
}; };
export const _unbindTree = (node) => { export const _unbindTree = (node) => {

View File

@ -1,10 +1,10 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '../../.profile/playwright/fixture.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
test('modular unit tests and benchmark', async ({ page }) => { test('modular unit tests and benchmark', async ({ page }) => {
page.on('console', msg => console.log('BROWSER LOG:', msg.text())); page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
await page.goto('http://localhost:8081/test/index.html'); await page.goto('/test/index.html');
await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 10000 }); await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 10000 });
const status = await page.evaluate(() => window.testStatus); const status = await page.evaluate(() => window.testStatus);

View File

@ -26,6 +26,51 @@ window.testComponent = async function() {
throw new Error('Component rendering failed'); throw new Error('Component rendering failed');
} }
// Slots inside a rendering template must be projected before that template
// is instantiated, so every rendered item receives its own slot content.
Component.register('SlotTemplateTest', container => {
container.state.items = ['first', 'second'];
}, document.createRange().createContextualFragment(`
<div>
<template $each="this.state.items" as="item">
<div class="row"><div slot-id="item-actions"></div></div>
</template>
</div>
`).firstElementChild);
const slotComp = document.createElement('SlotTemplateTest');
slotComp.innerHTML = '<div slot="item-actions" class="slot-action" $text="item"></div>';
document.body.appendChild(slotComp);
__unsafeRefreshState(document.documentElement);
await new Promise(r => setTimeout(r, 50));
const slotActions = slotComp.querySelectorAll('.slot-action');
if (slotActions.length !== 2 || [...slotActions].map(node => node.textContent).join(',') !== 'first,second') {
throw new Error('Slots inside rendering templates failed');
}
// A component created from a parent $each must own `this`; its creator is
// available through this.parent. This is the context used by AutoForm
// field components such as TagsInput and IconPicker.
Component.register('NestedContextChild', container => {
container.state.label = 'child';
}, document.createRange().createContextualFragment('<div class="nested-context" $text="this.state.label + \'-\' + this.parent.state.label"></div>').firstChild);
Component.register('NestedContextParent', container => {
container.state.label = 'parent';
container.state.items = [1, 2];
}, document.createRange().createContextualFragment('<div><template $each="this.state.items"><NestedContextChild></NestedContextChild></template></div>').firstChild);
const contextParent = document.createElement('NestedContextParent');
document.body.appendChild(contextParent);
__unsafeRefreshState(document.documentElement);
await new Promise(r => setTimeout(r, 50));
const nestedContexts = contextParent.querySelectorAll('.nested-context');
if (nestedContexts.length !== 2 || [...nestedContexts].some(node => node.textContent !== 'child-parent')) {
throw new Error('Nested component this/parent context failed');
}
console.log('component.js tests passed'); console.log('component.js tests passed');
return true; return true;
} }

View File

@ -58,6 +58,10 @@ window.testDom = async function() {
input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('input'));
if (state.val !== 'changed') throw new Error('$bind writeback failed'); if (state.val !== 'changed') throw new Error('$bind writeback failed');
input.value = '';
input.dispatchEvent(new Event('input'));
if (state.val !== '') throw new Error('$bind must preserve an empty text value');
// 6. Double evaluation ($$ prefix) // 6. Double evaluation ($$ prefix)
console.log('Testing double evaluation ($$)...'); console.log('Testing double evaluation ($$)...');
document.body.innerHTML = ` document.body.innerHTML = `