Skip to content

Commit 45ef57c

Browse files
msg perf monaco
1 parent 9a5ad34 commit 45ef57c

4 files changed

Lines changed: 521 additions & 94 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { act, render, waitFor } from '../../test-utils';
2+
import { KowlJsonView } from './KowlJsonView';
3+
4+
const { editorLayoutSpy, editorPropsSpy } = vi.hoisted(() => ({
5+
editorLayoutSpy: vi.fn(),
6+
editorPropsSpy: vi.fn(),
7+
}));
8+
9+
vi.mock('./KowlEditor', async () => {
10+
const React = await import('react');
11+
12+
return {
13+
__esModule: true,
14+
default: (props: any) => {
15+
editorPropsSpy(props);
16+
17+
React.useEffect(() => {
18+
props.onMount?.({
19+
layout: editorLayoutSpy,
20+
});
21+
}, [props.onMount]);
22+
23+
return <div data-testid="mock-kowl-editor">{props.value}</div>;
24+
},
25+
};
26+
});
27+
28+
describe('KowlJsonView', () => {
29+
let originalResizeObserver: typeof ResizeObserver | undefined;
30+
let resizeCallback: ResizeObserverCallback | undefined;
31+
let currentSize = { width: 640, height: 384 };
32+
let getBoundingClientRectSpy: ReturnType<typeof vi.spyOn>;
33+
34+
beforeEach(() => {
35+
editorLayoutSpy.mockReset();
36+
editorPropsSpy.mockReset();
37+
resizeCallback = undefined;
38+
currentSize = { width: 640, height: 384 };
39+
40+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
41+
callback(0);
42+
return 1;
43+
});
44+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
45+
46+
originalResizeObserver = globalThis.ResizeObserver;
47+
class ResizeObserverMock {
48+
observe = vi.fn();
49+
disconnect = vi.fn();
50+
51+
constructor(callback: ResizeObserverCallback) {
52+
resizeCallback = callback;
53+
}
54+
}
55+
56+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
57+
getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
58+
width: currentSize.width,
59+
height: currentSize.height,
60+
top: 0,
61+
left: 0,
62+
right: currentSize.width,
63+
bottom: currentSize.height,
64+
x: 0,
65+
y: 0,
66+
toJSON: () => ({}),
67+
}));
68+
});
69+
70+
afterEach(() => {
71+
vi.unstubAllGlobals();
72+
getBoundingClientRectSpy.mockRestore();
73+
74+
if (originalResizeObserver) {
75+
globalThis.ResizeObserver = originalResizeObserver;
76+
} else {
77+
delete (globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
78+
}
79+
});
80+
81+
test('uses the lightweight read-only monaco preset and manually lays out the editor', async () => {
82+
render(<KowlJsonView srcObj={{ market: 'BSEX' }} />);
83+
84+
await waitFor(() => {
85+
expect(editorLayoutSpy).toHaveBeenCalledWith({ width: 640, height: 384 });
86+
});
87+
88+
expect(editorPropsSpy).toHaveBeenCalled();
89+
expect(editorPropsSpy.mock.lastCall?.[0].options).toMatchObject({
90+
readOnly: true,
91+
domReadOnly: true,
92+
automaticLayout: false,
93+
folding: false,
94+
showFoldingControls: 'never',
95+
lineNumbers: 'off',
96+
renderLineHighlight: 'none',
97+
renderValidationDecorations: 'off',
98+
hover: { enabled: false },
99+
links: false,
100+
matchBrackets: 'never',
101+
stickyScroll: { enabled: false },
102+
guides: {
103+
indentation: false,
104+
highlightActiveIndentation: false,
105+
bracketPairs: false,
106+
bracketPairsHorizontal: false,
107+
highlightActiveBracketPair: false,
108+
},
109+
unicodeHighlight: {
110+
ambiguousCharacters: false,
111+
invisibleCharacters: false,
112+
},
113+
});
114+
});
115+
116+
test('relayouts only when the container size changes', async () => {
117+
render(<KowlJsonView srcObj={{ market: 'BSEX' }} />);
118+
119+
await waitFor(() => {
120+
expect(editorLayoutSpy).toHaveBeenCalledTimes(1);
121+
});
122+
123+
act(() => {
124+
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
125+
});
126+
127+
expect(editorLayoutSpy).toHaveBeenCalledTimes(1);
128+
129+
currentSize = { width: 800, height: 480 };
130+
131+
act(() => {
132+
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
133+
});
134+
135+
await waitFor(() => {
136+
expect(editorLayoutSpy).toHaveBeenCalledTimes(2);
137+
});
138+
139+
expect(editorLayoutSpy).toHaveBeenLastCalledWith({ width: 800, height: 480 });
140+
});
141+
});

frontend/src/components/misc/KowlJsonView.tsx

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,74 @@
1111

1212
import { Box } from '@redpanda-data/ui';
1313
import { observer } from 'mobx-react';
14-
import { type CSSProperties, useMemo } from 'react';
15-
import KowlEditor from './KowlEditor';
14+
import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react';
15+
import KowlEditor, { type IStandaloneCodeEditor } from './KowlEditor';
1616

1717
export const KowlJsonView = observer(
1818
(props: {
1919
srcObj: object | string | null | undefined;
2020
style?: CSSProperties;
2121
}) => {
22+
const containerRef = useRef<HTMLDivElement | null>(null);
23+
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
24+
const frameRef = useRef<number | null>(null);
25+
const lastSizeRef = useRef({ width: 0, height: 0 });
2226
const str = useMemo(
2327
() => (typeof props.srcObj === 'string' ? props.srcObj : JSON.stringify(props.srcObj, undefined, 4)),
2428
[props.srcObj],
2529
);
2630

31+
const scheduleLayout = useCallback(() => {
32+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
33+
34+
frameRef.current = requestAnimationFrame(() => {
35+
frameRef.current = null;
36+
37+
const editor = editorRef.current;
38+
const container = containerRef.current;
39+
if (!editor || !container) return;
40+
41+
const { width, height } = container.getBoundingClientRect();
42+
if (width === 0 || height === 0) return;
43+
44+
const nextSize = {
45+
width: Math.floor(width),
46+
height: Math.floor(height),
47+
};
48+
49+
if (nextSize.width === lastSizeRef.current.width && nextSize.height === lastSizeRef.current.height) return;
50+
51+
lastSizeRef.current = nextSize;
52+
editor.layout(nextSize);
53+
});
54+
}, []);
55+
56+
useEffect(() => {
57+
const container = containerRef.current;
58+
if (!container) return;
59+
60+
scheduleLayout();
61+
62+
if (typeof ResizeObserver === 'undefined') {
63+
window.addEventListener('resize', scheduleLayout);
64+
65+
return () => {
66+
window.removeEventListener('resize', scheduleLayout);
67+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
68+
};
69+
}
70+
71+
const observer = new ResizeObserver(() => {
72+
scheduleLayout();
73+
});
74+
observer.observe(container);
75+
76+
return () => {
77+
observer.disconnect();
78+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
79+
};
80+
}, [scheduleLayout]);
81+
2782
return (
2883
<Box
2984
display="block"
@@ -33,19 +88,39 @@ export const KowlJsonView = observer(
3388
style={props.style}
3489
position="relative"
3590
>
36-
{/*
37-
We have a problem in Safari with Monaco editor, when used with automaticLayout: true, which is a default,
38-
it causes an infinite loop in Safari. It recalculates in a wrong way, changes the dimensions of a parent
39-
and that triggers ResizeObserver again (see the internal implementation).
40-
We tried to play with overflow, boxSizing, even manually using ResizeObserver.
41-
Changing the parent to absolutely positioned element works around the issue for now.
42-
*/}
43-
<Box position="absolute" h="full" w="full">
91+
<Box ref={containerRef} position="absolute" h="full" w="full">
4492
<KowlEditor
4593
value={str}
4694
language="json"
95+
onMount={(editor) => {
96+
editorRef.current = editor;
97+
lastSizeRef.current = { width: 0, height: 0 };
98+
scheduleLayout();
99+
}}
47100
options={{
48101
readOnly: true,
102+
domReadOnly: true,
103+
automaticLayout: false,
104+
folding: false,
105+
showFoldingControls: 'never',
106+
lineNumbers: 'off',
107+
renderLineHighlight: 'none',
108+
renderValidationDecorations: 'off',
109+
hover: { enabled: false },
110+
links: false,
111+
matchBrackets: 'never',
112+
stickyScroll: { enabled: false },
113+
guides: {
114+
indentation: false,
115+
highlightActiveIndentation: false,
116+
bracketPairs: false,
117+
bracketPairsHorizontal: false,
118+
highlightActiveBracketPair: false,
119+
},
120+
unicodeHighlight: {
121+
ambiguousCharacters: false,
122+
invisibleCharacters: false,
123+
},
49124
}}
50125
/>
51126
</Box>

0 commit comments

Comments
 (0)