fix(editor): cross browser test stability (#14897)

#### PR Dependency Tree


* **PR #14897** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability of shape and connector detection by forcing full
DOM renders during waits.
* Fixed race conditions in code-block theme loading and cleanup when
components unmount.
* Refined viewport element discovery to correctly handle
rotated/canvas-layer elements and avoid stale DOM removal.

* **Tests**
  * Increased polling timeouts and retries to reduce flakiness.
* Disabled per-file parallelism and ensured test setup performs full
cleanup before starting; extended test timeout.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-04 20:07:40 +08:00
committed by GitHub
parent 9e412f58ec
commit 4e169ea5c7
8 changed files with 155 additions and 111 deletions

View File

@@ -39,10 +39,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
private readonly _loadTheme = async (
highlighter: HighlighterCore
): Promise<void> => {
// It is possible that by the time the highlighter is ready all instances
// have already been unmounted. In that case there is no need to load
// themes or update state.
if (CodeBlockHighlighter._refCount === 0) {
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
@@ -51,7 +48,17 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
this._darkThemeKey = (await normalizeGetter(darkTheme)).name;
this._lightThemeKey = (await normalizeGetter(lightTheme)).name;
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
await highlighter.loadTheme(darkTheme, lightTheme);
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
this.highlighter$.value = highlighter;
};
@@ -83,30 +90,18 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
}
override unmounted(): void {
CodeBlockHighlighter._refCount--;
CodeBlockHighlighter._refCount = Math.max(
0,
CodeBlockHighlighter._refCount - 1
);
this.highlighter$.value = null;
}
// Dispose the shared highlighter **after** any in-flight creation finishes.
if (CodeBlockHighlighter._refCount !== 0) {
return;
}
const doDispose = (highlighter: HighlighterCore | null) => {
if (highlighter) {
highlighter.dispose();
}
CodeBlockHighlighter._sharedHighlighter = null;
CodeBlockHighlighter._highlighterPromise = null;
};
if (CodeBlockHighlighter._sharedHighlighter) {
// Highlighter already created dispose immediately.
doDispose(CodeBlockHighlighter._sharedHighlighter);
} else if (CodeBlockHighlighter._highlighterPromise) {
// Highlighter still being created wait for it, then dispose.
CodeBlockHighlighter._highlighterPromise
.then(doDispose)
.catch(console.error);
}
private static _isHighlighterInUse(highlighter: HighlighterCore) {
return (
CodeBlockHighlighter._refCount > 0 &&
CodeBlockHighlighter._sharedHighlighter === highlighter
);
}
}

View File

@@ -168,6 +168,8 @@ export class DomRenderer {
pendingUpdates: new Map(),
};
private readonly _pendingElements = new Map<string, SurfaceElementModel>();
private _lastViewportBounds: Bound | null = null;
private _lastZoom: number | null = null;
private _lastUsePlaceholder: boolean = false;
@@ -184,6 +186,8 @@ export class DomRenderer {
provider: Partial<EnvProvider>;
private readonly _surfaceModel: SurfaceBlockModel;
usePlaceholder = false;
viewport: Viewport;
@@ -204,6 +208,7 @@ export class DomRenderer {
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this._surfaceModel = options.surfaceModel;
this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService);
@@ -367,7 +372,11 @@ export class DomRenderer {
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markElementDirty(
payload.id,
UpdateType.ELEMENT_ADDED,
payload as unknown as SurfaceElementModel
);
this._markViewportDirty();
this.refresh();
})
@@ -381,7 +390,11 @@ export class DomRenderer {
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
this._markElementDirty(
payload.model.id,
UpdateType.ELEMENT_UPDATED,
payload.model as unknown as SurfaceElementModel
);
if (payload.props['index'] || payload.props['groupId']) {
this._markViewportDirty();
}
@@ -522,8 +535,22 @@ export class DomRenderer {
this.refresh();
};
private _markElementDirty(elementId: string, updateType: UpdateType) {
private _markElementDirty(
elementId: string,
updateType: UpdateType,
elementModel?: SurfaceElementModel
) {
this._updateState.dirtyElementIds.add(elementId);
if (updateType === UpdateType.ELEMENT_REMOVED) {
this._pendingElements.delete(elementId);
} else {
const model =
elementModel ?? this._surfaceModel.getElementById(elementId);
if (model) {
this._pendingElements.set(elementId, model as SurfaceElementModel);
}
}
const currentUpdates =
this._updateState.pendingUpdates.get(elementId) || [];
if (!currentUpdates.includes(updateType)) {
@@ -572,6 +599,51 @@ export class DomRenderer {
return this._lastUsePlaceholder !== this.usePlaceholder;
}
private _elementInViewport(
elementModel: SurfaceElementModel,
viewportBounds: Bound
) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
return (
display && intersects(getBoundWithRotation(elementModel), viewportBounds)
);
}
private _getPendingElementsInViewport(viewportBounds: Bound) {
const elements: SurfaceElementModel[] = [];
for (const [id, elementModel] of this._pendingElements) {
this._pendingElements.delete(id);
if (this._elementInViewport(elementModel, viewportBounds)) {
elements.push(elementModel);
}
}
return elements;
}
private _getElementsInViewport(viewportBounds: Bound) {
const elements = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsById = new Map<string, SurfaceElementModel>();
for (const elementModel of elements) {
if (this._elementInViewport(elementModel, viewportBounds)) {
elementsById.set(elementModel.id, elementModel);
this._pendingElements.delete(elementModel.id);
}
}
for (const elementModel of this._getPendingElementsInViewport(
viewportBounds
)) {
elementsById.set(elementModel.id, elementModel);
}
return Array.from(elementsById.values());
}
private _updateLastState() {
const { viewportBounds, zoom } = this.viewport;
this._lastViewportBounds = {
@@ -604,41 +676,33 @@ export class DomRenderer {
}
// Only update dirty elements
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsInViewport = this._getElementsInViewport(viewportBounds);
const visibleElementIds = new Set<string>();
// 1. Update dirty elements
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
for (const elementModel of elementsInViewport) {
visibleElementIds.add(elementModel.id);
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
}
@@ -677,59 +741,32 @@ export class DomRenderer {
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
// Step 1: Handle elements whose models are deleted from the surface
const prevRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of prevRenderedElementIds) {
const modelExists = this.layerManager.layers.some(layer =>
layer.elements.some(elem => (elem as SurfaceElementModel).id === id)
);
if (!modelExists) {
const domElem = this._elementsMap.get(id);
if (domElem) {
domElem.remove();
this._elementsMap.delete(id);
elementsToRemove.push(domElem);
}
}
}
// Step 2: Render elements in the current viewport
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsInViewport = this._getElementsInViewport(viewportBounds);
const visibleElementIds = new Set<string>();
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
for (const elementModel of elementsInViewport) {
visibleElementIds.add(elementModel.id);
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
// Full render
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
// Step 3: Remove DOM elements that are in _elementsMap but were not processed in Step 2
const currentRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of currentRenderedElementIds) {
if (!visibleElementIds.has(id)) {
@@ -744,7 +781,6 @@ export class DomRenderer {
}
}
// Step 4: Notify about changes
if (addedElements.length > 0 || elementsToRemove.length > 0) {
this.elementsUpdated.next({
elements: Array.from(this._elementsMap.values()),

View File

@@ -29,6 +29,11 @@ async function waitForConnectorElement(
continue;
}
if (surfaceView.renderer instanceof DomRenderer) {
surfaceView.renderer.markElementDirty(connectorId);
surfaceView.renderer.forceFullRender();
}
const connectorElement = surfaceView.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${connectorId}"]`
);

View File

@@ -16,11 +16,16 @@ function expectPxCloseTo(
async function waitForShapeElement(
surfaceView: ReturnType<typeof getSurface>,
shapeId: string,
timeout = 1000
timeout = 5000
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
if (surfaceView.renderer instanceof DomRenderer) {
surfaceView.renderer.markElementDirty(shapeId);
surfaceView.renderer.forceFullRender();
}
const shapeElement = surfaceView.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);

View File

@@ -275,9 +275,9 @@ describe('hotkey/bracket/linked-page', () => {
await wait();
const codeRichText = getRichTextByBlockId(codeId);
setTextSelection(codeId, 1, 0);
await wait();
const rightContext = mockKeyboardContext();
rightHandler(rightContext.ctx);
expect(rightContext.preventDefault).not.toHaveBeenCalled();
expect(codeRichText.inlineEditor.yTextString).toBe('()');
});

View File

@@ -127,6 +127,8 @@ export async function setupEditor(
const options: SetupEditorOptions = optionsInput ?? {};
const enableDomRenderer = options?.enableDomRenderer ?? false;
await cleanup();
const collection = new TestWorkspace(createCollectionOptions());
collection.storeExtensions = storeExtensions;
collection.meta.initialize();

View File

@@ -16,6 +16,7 @@ export default defineConfig(_configEnv =>
plugins: [vanillaExtractPlugin()],
test: {
include: ['src/__tests__/**/*.spec.ts'],
fileParallelism: false,
retry: process.env.CI === 'true' ? 3 : 0,
browser: {
enabled: true,

View File

@@ -23,7 +23,7 @@ export default defineConfig({
test: {
setupFiles: [resolve(rootDir, './scripts/setup/global.ts')],
include: ['./test/**/*.spec.ts'],
testTimeout: 5000,
testTimeout: 30000,
pool: 'forks',
maxWorkers: 1,
coverage: {