Budget growing history and import recovery encoding
This commit is contained in:
+25
-2
@@ -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.
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;}}));
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user