Budget growing history and import recovery encoding

This commit is contained in:
2026-11-11 04:16:21 +08:00
parent b4c40d3087
commit 210bc14074
11 changed files with 120 additions and 22 deletions
+25 -2
View File
@@ -1003,8 +1003,21 @@ or scheduling failures retain the pending Root for retry and produce no partial
storage commit. Initial DTO membership copying, synchronous preparation, unknown
subtrees, first-time immutable normalization and individual scalar operations
remain synchronous. This is partial time slicing, not a strict whole-Root latency
bound. In particular, large history/import objects still need producer ownership
review before their internal loops can safely be deferred.
bound. Grouped history entries now keep shape ownership for newly built changes and
operation-ID arrays, including undo/redo lifecycle remaps. Import runners keep it
for their own job envelopes, completed-key arrays and binding dictionaries; newly
allocated identity tables use the same private constructor. The engine's source
proof refresh preserves this envelope ownership. Arbitrary host checkpoint
replacements keep defensive capture. These constructors still freeze nested data
before freezing their container, retaining the prior deep-freeze behavior; their
brand alone never permits mutable child values to escape capture.
Primitive values captured in these tables cannot change while suspended. Their
normalization and UTF-8 sizing now run in the cooperative traversal rather than
the preparation scan. Unknown object/proxy reflection stays entirely within the
original normalizer; preparation adds no proxy traps or callback invocations.
Membership copying and the preparation scan itself remain synchronous, as do
unknown subtrees and individual native operations.
A blocked schema upgrade retires its pending open. If the old connection closes
later, that abandoned request aborts in `onupgradeneeded` rather than silently
@@ -1102,3 +1115,13 @@ CPU/allocation diagnostics found repeated history-transition validation under
resource retention. The production mixed-input probe and a same-machine old
history-path control both pass, but observed total runtimes are similar; this
change demonstrates reduced repeated interpretation, not an end-to-end speedup.
The 10k production copy/input probe keeps one native editor focused from the
first presented copy through the 50% and 90% publication thresholds, read from
the actual import status UI. Samples record phase, committed count, focus/DOM
retention and input-to-two-rAF delay; middle/tail samples also record CDP dispatch
round-trip time separately. That round trip includes transport and driver work,
and is not a precise browser queue-time measurement. The final server document
must retain the original's later edits while the copy retains its earlier source
snapshot. Smaller scenarios retain the early burst because they can finish
before a later publication threshold is observed.
+6 -5
View File
@@ -1,3 +1,4 @@
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
@@ -151,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
if (historyEntryId) {
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
entries.set(entry.id, freeze({ ...entry, operationIds: [...entry.operationIds, ...operationIds] }));
entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) })));
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
@@ -232,10 +233,10 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined;
if (settings.groupId && prior?.groupId === settings.groupId) {
historyEntryId = prior.id;
entries.set(prior.id, freeze({ ...prior, changes: [...prior.changes, ...changes] }));
entries.set(prior.id, dataRecord(freeze({ ...prior, changes: dataArray(freeze([...prior.changes, ...changes])) })));
} else {
historyEntryId = uniqueId(new Set());
entries.set(historyEntryId, freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: [...changes], operationIds: [], ...(settings.groupId ? { groupId: settings.groupId } : {}) }));
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId);
}
while (undo.length > limit) entries.delete(undo.shift()!);
@@ -266,8 +267,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const entries=new Map(state.history.entries);
// Later redo entries belong to the lifecycle restored by this explicit history action.
// Frozen operations retain their original identities and incarnations.
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));
entries.set(id,freeze({...entry,changes:direction==='undo'?invert(changes):changes}));
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
return publish(document, changes, direction, history, id);
} catch (error) { return reject(error); }
+2 -1
View File
@@ -1,3 +1,4 @@
import {dataRecord} from '../canvas-core/data-containers.ts';
import {createImportReadProof,IMPORT_PROOF_BLOCK_BYTES} from './import-read-proof.ts';
import {isImportByteFailure} from './import-byte-reader.ts';
import {assertImportEvidence,importSeals,importsMaySend} from './import-verification.ts';
@@ -674,7 +675,7 @@ export async function openProjectEngine(options: EngineOptions) {
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
if(!context||context.fileId!==job.source.fileId)throw fault('import_source_unavailable');
const {sha256:_,integrityError:__,...source}=job.source;
return {...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}};
return dataRecord(freeze({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}}));
}
function failImportVerification(job:ImportJob,error:unknown){
const code=(error as {code?:string}).code??'import_source_verification_failed';
+2 -1
View File
@@ -1,3 +1,4 @@
import {dataRecord} from '../canvas-core/data-containers.ts';
import type {Id} from '../canvas-core/types.ts';
import {fault} from '../canvas-core/values.ts';
type Ids=Readonly<Record<string,Id>>;
@@ -20,7 +21,7 @@ export function createImportIdentities(){
(ids as Record<string,Id>)[key]=id;used.add(id);return id;
},
has:(id:Id)=>used.has(id),
finish:():Ids=>owned?{...ids}:ids,
finish:():Ids=>owned?dataRecord(ids):ids,
});
};
return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}}));
+3 -2
View File
@@ -1,3 +1,4 @@
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
import {createImportBatches} from './import-batches.ts';
import {createImportIdentities} from './import-identities.ts';
import {validateImportReadProof,type ImportReadProof} from './import-read-proof.ts';
@@ -146,7 +147,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
else if(!equal(job.source,value.source))throw fault('invalid_import_checkpoint');
return value;
}
function next(job:ImportJob,patch:Partial<ImportJob>):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;return freeze(checkpointEvidence(value));}
function next(job:ImportJob,patch:Partial<ImportJob>):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;const checked=checkpointEvidence(value);return checked===value?dataRecord(freeze(value)):freeze(checked);}
function publish(job:ImportJob,patch:Partial<ImportJob>){const value=next(job,patch);ports.publish(value,job.generation);if(!equal(current(),value))throw fault('invalid_import_publish');return value;}
function prepare(line:ImportLine,job:ImportJob){
const ids=identities(job.stableIds,allocateIdentity);
@@ -161,7 +162,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
function completed(job:ImportJob,units:readonly ImportUnit[],pending:readonly ImportLocator[]){
const bindings:Record<Id,ImportBinding>=Object.assign(Object.create(null),job.bindings??{});
for(const unit of units)for(const change of unit.changes)if(change.kind==='element'&&change.before===null&&change.after)bindings[change.id]={kind:'element',incarnation:change.after.incarnation};else if(change.kind==='canvas'&&change.before===null&&change.after)bindings[change.id]={kind:'canvas',...(change.after.parent?{entryElementId:change.after.parent.entryElementId}:{})};
return next(job,{pending,committedSourceKeys:[...job.committedSourceKeys,...units.map(unit=>unit.key)],bindings:{...bindings}});
return next(job,{pending,committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))});
}
function commit(units:readonly ImportUnit[],job:ImportJob,value:ImportJob){const base=current();if(base.generation!==job.generation)throw fault('stale_import_job');value=freeze(checkpointEvidence(value));const start=now(),result=ports.commit(units,value,job.generation),elapsed=Math.max(0,now()-start);if(!['applied','rejected'].includes(result?.status)||!equal(current(),result.status==='applied'?value:base))throw fault('invalid_import_commit');if(result.status==='applied')batches.record(units.length,elapsed);return result;}
async function pump(signal:AbortSignal){
+18 -7
View File
@@ -71,21 +71,30 @@ export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number
return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload));
}
type Prepared = Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]};
type Scalar = null|string|number|boolean|undefined|symbol;
type Prepared = Scalar | Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]};
// Read unknown objects synchronously, before the first suspension. Shape-owned
// containers can include external values; each such occurrence is captured now.
// Only our fresh container shape and previously proven encoded values defer.
function prepareEncoding(input:unknown) {
const normalize=createEncoder(),visiting=new Set<object>();
function prepare(value:unknown,depth:number,key:string):Prepared {
if(!value||typeof value!=='object')return normalize(value,depth,key);
const kind=typeof value;
if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){
if(depth>64&&value!==undefined&&typeof value!=='symbol')throw fault('invalid_recovery_payload');
// Primitives cannot change while suspended. Defer normalization/UTF-8 work
// as well as allocation of their Encoded facts to the budgeted traversal.
return value as Scalar;
}
if(typeof value!=='object')return normalize(value,depth,key);
const cached=immutable.get(value);
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
if(!isDataContainer(value))return normalize(value,depth,key);
// Prototype toJSON changes must retain ordinary JSON semantics, including
// accessors. Inspect descriptors without evaluating them an extra time.
let prototype:object|null=value,hasToJSON=false;
while(prototype){if(Object.getOwnPropertyDescriptor(prototype,'toJSON')){hasToJSON=true;break;}prototype=Object.getPrototypeOf(prototype);}
if(!isDataContainer(value)||hasToJSON)return normalize(value,depth,key);
if(hasToJSON)return normalize(value,depth,key);
if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload');
visiting.add(value);
try {
@@ -95,7 +104,7 @@ function prepareEncoding(input:unknown) {
return {source:value,depth,children,keys};
}finally{visiting.delete(value);}
}
return prepare(input,0,'');
return {plan:prepare(input,0,''),normalize};
}
/** Capture external data immediately; cooperatively encode stable owned tables.
@@ -103,15 +112,17 @@ function prepareEncoding(input:unknown) {
* The returned image has exactly the synchronous codec's wire representation. */
export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown,options:ImportScheduling={}):Promise<RecoveryImage> {
validateHeader(formatVersion,rootRevision,localRevision);
const plan=prepareEncoding(payload);
const {plan,normalize}=prepareEncoding(payload);
const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise<void>}}).scheduler;
const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)});
function* encode(plan:Prepared):Generator<Promise<void>,Encoded,void>{
function* encode(plan:Prepared,depth=0,key=''):Generator<Promise<void>,Encoded,void>{
if(plan===null||typeof plan!=='object')return normalize(plan,depth,key);
if('height' in plan)return plan;
let height=0,bytes=2,reusable=true,properties=0;
const array=plan.keys===undefined,result:Json[]|Record<string,Json>=array?[]:{};
for(let index=0;index<plan.children.length;index++){
const entry=plan.children[index],child='height' in entry?entry:yield* encode(entry);
const entry=plan.children[index],name=plan.keys?.[index]??String(index);
const child=entry===null||typeof entry!=='object'?normalize(entry,plan.depth+1,name):'height' in entry?entry:yield* encode(entry,plan.depth+1,name);
reusable&&=child.reusable;
if(array){
if(child.value===undefined&&plan.depth+1>64)throw fault('invalid_recovery_payload');
+12
View File
@@ -226,3 +226,15 @@ test('transport preparation cannot rewrite a committed document change or its id
const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}});
assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
});
test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const editor=setup();
for(let i=0;i<300;i++)assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'step-'+i}},{groupId:'large-history'}).status,'applied');
const entry=editor.getSnapshot().history.entries.get(editor.getSnapshot().history.undo.at(-1));
assert.equal(entry.changes.length,300);let tick=0,yields=0;
const pending=sealRecoveryImageAsync(17,1,1,entry,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'later'}},{groupId:'large-history'}).status,'applied');
const image=await pending;assert.ok(yields>0);assert.deepEqual(image.payload,JSON.parse(JSON.stringify(entry)));assert.equal(image.payload.changes.length,300);
assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'a');
});
+9
View File
@@ -162,3 +162,12 @@ test('a cheap runner can use the engine-sized ceiling without dropping records o
test('the larger ceiling still backs off when a measured commit exceeds the unchanged time budget',async()=>{
let clock=0;const records=Array.from({length:500},(_,index)=>({id:`cost-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>clock}}),commit=f.ports.commit;f.ports.commit=(units,...args)=>{clock+=units.length>40?12:1;return commit(units,...args);};await f.runner.start();const sizes=f.calls.map(batch=>batch.length),wide=sizes.findIndex(size=>size>40);assert.ok(wide>=0);assert.ok(sizes[wide+1]<sizes[wide]);assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,500);assert.deepEqual(f.calls.flat(),records.map(row=>row.id));assert.equal(f.errors.length,0);await f.runner.close();
});
test('growing import tables yield when encoded while keeping captured IDs and bindings stable',async()=>{
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const f=fixture(Array.from({length:300},(_,i)=>({id:'encode-'+i})));await f.runner.start();
const job=f.root.job;assert.equal(job.committedSourceKeys.length,300);let ticks=0,yields=0;
const image=await sealRecoveryImageAsync(17,1,1,job,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.ok(yields>0);assert.deepEqual(image,sealRecoveryImage(17,1,1,job));
assert.equal(Object.keys(image.payload.stableIds).length,300);assert.equal(Object.keys(image.payload.bindings).length,300);await f.runner.close();
});
+24
View File
@@ -72,3 +72,27 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro
assert.ok(await sealRecoveryImageAsync(17,1,1,nested(64,dataRecord({omitted:undefined}))));
await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/);
});
test('primitive normalization and UTF-8 sizing occur inside the cooperative slice',async()=>{
const {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const input=dataArray(Array.from({length:1000},(_,i)=>'budgeted-中文😀-'+i)),stringify=JSON.stringify;
let strings=0,tick=0,atFirstYield;
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('budgeted-'))strings++;return Reflect.apply(stringify,this,[value,...args]);};
try{
const image=await sealRecoveryImageAsync(17,1,1,input,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirstYield??=strings;}});
assert.ok(atFirstYield>0&&atFirstYield<=128,'all scalar encoding ran before the first yield');
assert.equal(strings,1000);assert.deepEqual(image.payload,input);
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
}finally{JSON.stringify=stringify;}
});
test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
let events=[];
const proxy=new Proxy({text:'external'},Object.fromEntries(['get','getPrototypeOf','getOwnPropertyDescriptor','ownKeys','isExtensible'].map(name=>[name,(...args)=>{events.push([name,String(args[1]??'')]);return Reflect[name](...args);}])));
const payload=dataRecord({proxy}),expected=sealRecoveryImage(17,1,1,payload),before=events;events=[];
const actual=await sealRecoveryImageAsync(17,1,1,payload);
assert.deepEqual(actual,expected);assert.deepEqual(events,before);
});
+18 -3
View File
@@ -22,7 +22,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
await evaluate(`(()=>{
const element=document.activeElement,samples=[];
const input=()=>{
const sample={inputAt:performance.now(),value:element.value,copyActive:window.__canvasPerformance.read().localCompleteMs===null};
const sample={inputAt:performance.now(),value:element.value,phase:window.__copyInputProbe.phase,committed:window.__copyInputProbe.progress(),copyActive:window.__canvasPerformance.read().localCompleteMs===null};
samples.push(sample);
requestAnimationFrame(()=>requestAnimationFrame(()=>{
sample.frameAt=performance.now();sample.frameDelayMs=sample.frameAt-sample.inputAt;
@@ -31,7 +31,10 @@ export async function editDuringCopy({call,evaluate,wait,count}){
}));
};
element.addEventListener('input',input);
window.__copyInputProbe={element,samples,dispose(){element.removeEventListener('input',input);}};
window.__copyInputProbe={element,samples,phase:'early',progress(){
const status=document.querySelector('[aria-label="导入任务 1"] p[role="status"]')?.textContent??'';
return Number(status.match(/·\\s*(\\d+)\\s*项/)?.[1]??0);
},dispose(){element.removeEventListener('input',input);}};
})()`);
await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1});
await call('Input.insertText',{text:'复制期间输入'});
@@ -39,13 +42,25 @@ export async function editDuringCopy({call,evaluate,wait,count}){
await call('Input.insertText',{text});
await wait(`window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,'native input did not reach its next frame');
}
// Keep the same native editor alive through later publication and ACK work.
// Small scenarios can finish during the early burst; the scale gate uses 10k.
if(count>=10000)for(const [phase,fraction,text] of [['middle',.5,' M'],['tail',.9,' T']]){
await wait(`window.__copyInputProbe.progress()>=${Math.floor((count+3)*fraction)}`,'copy did not reach '+phase+' publication');
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy completed before '+phase+' input');
await evaluate(`window.__copyInputProbe.phase=${JSON.stringify(phase)};true`);
const started=performance.now();await call('Input.insertText',{text});
const dispatchRoundTripMs=performance.now()-started;
await wait(`window.__copyInputProbe.samples.at(-1)?.phase===${JSON.stringify(phase)}&&window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,phase+' input did not reach a frame');
await evaluate(`window.__copyInputProbe.samples.at(-1).dispatchRoundTripMs=${dispatchRoundTripMs};true`);
}
const result=await evaluate(`(()=>{
const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element};
probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result;
})()`);
assert.equal(result.retained,true,'copy discarded native input DOM or focus');
assert.ok(result.value.endsWith('复制期间输入 A B C'),'native composition/typing was overwritten');
assert.ok(result.value.endsWith(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'native composition/typing was overwritten');
assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion');
if(count>=10000)for(const phase of ['middle','tail'])assert.ok(result.samples.some(sample=>sample.phase===phase&&sample.copyActive),phase+' input was not observed before completion');
assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame');
assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy');
return result;
+1 -1
View File
@@ -94,7 +94,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
document.querySelector('[aria-label="深复制选中内容"]').click();
})()`);
copying=true;console.log('PERF copy requested');
if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+'复制期间输入 A B C','concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));}
if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));}
await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch');