fix(state): 修复动态组件上下文与模板插槽(by AI)
This commit is contained in:
parent
eb0145ea85
commit
ea96c4ef22
16
dist/state.js
vendored
16
dist/state.js
vendored
@ -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
2
dist/state.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = `
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user