Files
wordpress-theme-aksrt/tests/widget-system/repeater-row-key-stability.test.cjs
T
kate522 7a0445fc4a fix(admin): write group rows in place so drag keys stay stable
The previous fix inherited the drag key onto a replacement row object, but the row data is reactive: Vue hands the renderer a fresh proxy for every new object, so the WeakMap still missed and minted a new key on each keystroke. That remounted the row and dropped input focus after one character, so editing 执行价, 原价, 促销标签, 使用积分, 兑换时长 and 会员有效时间 was unusable.

updateRepeaterItem now writes through the existing row instead of spreading a new object, so the row identity survives across renders and the drag key stays valid. The emitted array is still a fresh array, so parents re-render normally, and rows that are not objects still fall back to a new object.

Verified in a real browser against the actual payment/vip schema: all six fields keep the same DOM node across repeated keystrokes, typed values reach the model, and add/remove still work. The contract test now simulates Vue reactive proxying and asserts row identity is preserved; it fails against the previous implementation. Full suite: 663 tests passing.
2026-10-18 21:59:06 +08:00

156 lines
6.5 KiB
JavaScript

'use strict';
/**
* AksField 的 group / repeater 行身份稳定性契约。
*
* 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把
* `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。服务端
* 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。
*
* 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时
* 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成
* 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写,
* 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const fieldVue = fs.readFileSync(
path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'AksField.vue'),
'utf8'
);
/**
* 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个
* 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。
*/
const proxyCache = new WeakMap();
const proxySet = new WeakSet();
function reactiveOf(value) {
if (!value || typeof value !== 'object' || proxySet.has(value)) return value;
let proxy = proxyCache.get(value);
if (!proxy) {
proxy = new Proxy(value, {
get(target, key, receiver) { return Reflect.get(target, key, receiver); },
set(target, key, next) { target[key] = next; return true; },
});
proxyCache.set(value, proxy);
proxySet.add(proxy);
}
return proxy;
}
/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */
function declaration(source, marker) {
const start = source.indexOf(marker);
assert.notStrictEqual(start, -1, `AksField.vue 缺少声明: ${marker}`);
const end = source.indexOf('\n', start);
return source.slice(start, end === -1 ? undefined : end);
}
/** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */
function functionBlock(source, marker) {
const start = source.indexOf(marker);
assert.notStrictEqual(start, -1, `AksField.vue 缺少函数: ${marker}`);
const open = source.indexOf('{', start);
assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
}
assert.fail(`花括号不配平: ${marker}`);
}
const prelude = [
declaration(fieldVue, 'const dragRowKeys = new WeakMap()'),
declaration(fieldVue, 'let dragRowKeySeed = 0'),
functionBlock(fieldVue, 'function repeaterDragKey(item)'),
functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'),
].join('\n');
/** 用真实的 props / emit 形状驱动这两个函数。 */
function makeField(rows) {
const emitted = [];
// props.modelValue 是 reactive 视图,正如真实组件收到的那样。
const props = { modelValue: rows.map(reactiveOf) };
const emit = (event, value) => emitted.push({ event, value });
const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`);
const api = factory(props, emit);
// 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。
const roundTrip = () => {
props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf);
};
return { api, props, emitted, roundTrip };
}
test('连续输入期间行身份不变,drag key 保持稳定', () => {
// 服务端历史行:没有 _id,key 只能由 WeakMap 派生。
const serverRows = [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
];
const { api, props, emitted, roundTrip } = makeField(serverRows);
const firstKey = api.repeaterDragKey(props.modelValue[0]);
const secondKey = api.repeaterDragKey(props.modelValue[1]);
assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key');
const firstRow = props.modelValue[0];
// 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。
let rows = serverRows;
for (const value of ['5', '50', '500']) {
api.updateRepeaterItem(0, 'points', value);
rows = emitted[emitted.length - 1].value;
roundTrip();
}
assert.strictEqual(rows[0].points, '500', '输入值应写进行内');
// 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。
assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变');
assert.strictEqual(
api.repeaterDragKey(props.modelValue[0]),
firstKey,
'行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦'
);
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变');
});
test('emit 出去的是新数组,父组件仍会重渲染', () => {
const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]);
const before = props.modelValue;
api.updateRepeaterItem(0, 'points', '9');
const after = emitted[emitted.length - 1].value;
assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染');
roundTrip();
assert.strictEqual(props.modelValue[0].points, '9');
});
test('行不是对象时退化为新建,不抛错', () => {
const { api, props, roundTrip } = makeField([]);
api.updateRepeaterItem(0, 'points', '7');
roundTrip();
assert.strictEqual(props.modelValue.length, 1);
assert.strictEqual(props.modelValue[0].points, '7');
});
test('带 _id 的行仍以 _id 作为 key', () => {
const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }];
const { api, props } = makeField(rows);
assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1');
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2');
});
test('新增行获得独立 key,不与既有行冲突', () => {
const { api, props } = makeField([{ points: 500 }, { points: 5000 }]);
const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row)));
assert.strictEqual(keys.size, 2);
assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key');
});