Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import Editor, { DiffEditor, loader, useMonaco } from '@willbooster/monaco-react

- `Editor`: Monaco standalone code editor component.
- `DiffEditor`: Monaco standalone diff editor component.
- `useMonaco`: React hook returning the initialized Monaco instance.
- `useMonaco`: React hook returning the initialized Monaco instance after hydration. Wrap components that call it in `Suspense`.
- `loader`: the `@willbooster/monaco-loader` instance used by the components.

### Editor
Expand Down
17 changes: 14 additions & 3 deletions e2e/next-app/app/editorProbe.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
'use client';

import { useState } from 'react';
import { Suspense, useState } from 'react';

import MonacoEditor, { DiffEditor, useMonaco } from '@willbooster/monaco-react';

export default function EditorProbe() {
const [editorStatus, setEditorStatus] = useState('editor-pending');
const [diffStatus, setDiffStatus] = useState('diff-pending');
const loadedMonaco = useMonaco();

return (
<>
<p data-testid="hook-status">{loadedMonaco?.editor ? 'hook-ok' : 'hook-pending'}</p>
<Suspense fallback={<p>hook-pending</p>}>
<HookStatus />
</Suspense>
<div data-testid="editor-status">{editorStatus}</div>
<MonacoEditor
height={120}
Expand All @@ -36,3 +37,13 @@ export default function EditorProbe() {
</>
);
}

function HookStatus() {
const loadedMonaco = useMonaco();

if (!loadedMonaco) {
return <p data-testid="hook-status">hook-pending</p>;
}

return <p data-testid="hook-status">{loadedMonaco.editor ? 'hook-ok' : 'hook-mismatch'}</p>;
}
73 changes: 57 additions & 16 deletions src/DiffEditor/DiffEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,60 @@
'use client';

import { useState, useRef, useCallback, useEffect } from 'react';
import { Suspense, useState, useRef, useCallback, useEffect } from 'react';
import type { ReactElement } from 'react';
import loader from '@willbooster/monaco-loader';

import MonacoContainer from '../MonacoContainer';
import useMount from '../hooks/useMount';
import useUpdate from '../hooks/useUpdate';
import useMonaco from '../hooks/useMonaco';
import { noop, getOrCreateModel } from '../utils';
import type { DiffEditorProps, MonacoDiffEditor } from './types';
import type { Monaco } from '..';

function DiffEditor({
function DiffEditor(props: DiffEditorProps): ReactElement {
const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props;

return (
<Suspense
fallback={
<MonacoContainer
width={width}
height={height}
isEditorReady={false}
loading={loading}
className={className}
wrapperProps={wrapperProps}
/>
}
>
<DiffEditorContent {...props} />
</Suspense>
);
}

function DiffEditorContent(props: DiffEditorProps): ReactElement {
const monaco = useMonaco();

if (!monaco) {
const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props;

return (
<MonacoContainer
width={width}
height={height}
isEditorReady={false}
loading={loading}
className={className}
wrapperProps={wrapperProps}
/>
);
}

return <MountedDiffEditor {...props} monaco={monaco} />;
}

function MountedDiffEditor({
monaco,
original,
modified,
language,
Expand All @@ -30,24 +73,23 @@ function DiffEditor({
wrapperProps = {},
beforeMount = noop,
onMount = noop,
}: DiffEditorProps): ReactElement {
}: DiffEditorProps & { monaco: Monaco }): ReactElement {
const [isEditorReady, setIsEditorReady] = useState(false);
const [isMonacoMounting, setIsMonacoMounting] = useState(true);
const editorRef = useRef<MonacoDiffEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const monacoRef = useRef<Monaco>(monaco);
const containerRef = useRef<HTMLDivElement | null>(null);
const onMountRef = useRef(onMount);
const beforeMountRef = useRef(beforeMount);
const preventCreation = useRef(false);

useMount(() => {
const cancelable = loader.init();
monacoRef.current = monaco;

cancelable
.then((monaco) => (monacoRef.current = monaco as Monaco) && setIsMonacoMounting(false))
.catch((error) => error?.type !== 'cancelation' && console.error('Monaco initialization: error:', error));

return () => (editorRef.current ? disposeEditor() : cancelable.cancel());
useMount(() => {
return () => {
if (editorRef.current) {
disposeEditor();
}
};
});

useUpdate(
Expand Down Expand Up @@ -149,7 +191,6 @@ function DiffEditor({
);

const setModels = useCallback(() => {
if (!monacoRef.current) return;
beforeMountRef.current(monacoRef.current);
const originalModel = getOrCreateModel(
monacoRef.current,
Expand Down Expand Up @@ -194,10 +235,10 @@ function DiffEditor({
}, [isEditorReady]);

useEffect(() => {
if (!isMonacoMounting && !isEditorReady) {
if (!isEditorReady) {
createEditor();
}
}, [isMonacoMounting, isEditorReady, createEditor]);
}, [isEditorReady, createEditor]);

function disposeEditor(): void {
const editor = editorRef.current;
Expand Down
74 changes: 58 additions & 16 deletions src/Editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client';

import { useState, useEffect, useRef, useCallback } from 'react';
import { Suspense, useState, useEffect, useRef, useCallback } from 'react';
import type { ReactElement } from 'react';
import loader from '@willbooster/monaco-loader';
import useMount from '../hooks/useMount';
import useUpdate from '../hooks/useUpdate';
import usePrevious from '../hooks/usePrevious';
import useMonaco from '../hooks/useMonaco';
import type { IDisposable, Uri, editor } from 'monaco-editor/esm/vs/editor/editor.api.js';
import { noop, getOrCreateModel } from '../utils';
import type { EditorProps } from './types';
Expand All @@ -14,7 +14,50 @@ import MonacoContainer from '../MonacoContainer';

const viewStates = new Map();

function Editor({
function Editor(props: EditorProps): ReactElement {
const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props;

return (
<Suspense
fallback={
<MonacoContainer
width={width}
height={height}
isEditorReady={false}
loading={loading}
className={className}
wrapperProps={wrapperProps}
/>
}
>
<EditorContent {...props} />
</Suspense>
);
}

function EditorContent(props: EditorProps): ReactElement {
const monaco = useMonaco();

if (!monaco) {
const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props;

return (
<MonacoContainer
width={width}
height={height}
isEditorReady={false}
loading={loading}
className={className}
wrapperProps={wrapperProps}
/>
);
}

return <MountedEditor {...props} monaco={monaco} />;
}

function MountedEditor({
monaco,
defaultValue,
defaultLanguage,
defaultPath,
Expand All @@ -39,10 +82,9 @@ function Editor({
onMount = noop,
onChange,
onValidate = noop,
}: EditorProps): ReactElement {
}: EditorProps & { monaco: Monaco }): ReactElement {
const [isEditorReady, setIsEditorReady] = useState(false);
const [isMonacoMounting, setIsMonacoMounting] = useState(true);
const monacoRef = useRef<Monaco | null>(null);
const monacoRef = useRef<Monaco>(monaco);
const editorRef = useRef<editor.IStandaloneCodeEditor | undefined>(undefined);
const containerRef = useRef<HTMLDivElement | null>(null);
const onMountRef = useRef(onMount);
Expand All @@ -53,14 +95,14 @@ function Editor({
const preventCreation = useRef(false);
const preventTriggerChangeEvent = useRef<boolean>(false);

useMount(() => {
const cancelable = loader.init();

cancelable
.then((monaco) => (monacoRef.current = monaco as Monaco) && setIsMonacoMounting(false))
.catch((error) => error?.type !== 'cancelation' && console.error('Monaco initialization: error:', error));
monacoRef.current = monaco;

return () => (editorRef.current ? disposeEditor() : cancelable.cancel());
useMount(() => {
return () => {
if (editorRef.current) {
disposeEditor();
}
};
});

useUpdate(
Expand Down Expand Up @@ -144,7 +186,7 @@ function Editor({
);

const createEditor = useCallback(() => {
if (!containerRef.current || !monacoRef.current) return;
if (!containerRef.current) return;
if (!preventCreation.current) {
beforeMountRef.current(monacoRef.current);
const autoCreatedModelPath = path || defaultPath;
Expand Down Expand Up @@ -201,10 +243,10 @@ function Editor({
}, [isEditorReady]);

useEffect(() => {
if (!isMonacoMounting && !isEditorReady) {
if (!isEditorReady) {
createEditor();
}
}, [isMonacoMounting, isEditorReady, createEditor]);
}, [isEditorReady, createEditor]);

// subscription
// to avoid unnecessary updates (attach - dispose listener) in subscription
Expand Down
2 changes: 1 addition & 1 deletion src/MonacoContainer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export interface ContainerProps {
height: number | string;
isEditorReady: boolean;
loading: ReactNode | string;
_ref: RefObject<HTMLDivElement | null>;
_ref?: RefObject<HTMLDivElement | null>;
className?: string;
wrapperProps?: object;
}
52 changes: 25 additions & 27 deletions src/hooks/useMonaco/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import { useState } from 'react';
import loader from '@willbooster/monaco-loader';
import { use, useSyncExternalStore } from 'react';

import useMount from '../useMount';
import type { Monaco } from '../..';
import initMonaco from './initMonaco';

function useMonaco(): Monaco | undefined {
const [monaco, setMonaco] = useState<Monaco | undefined>(loader.__getMonacoInstance() as Monaco | undefined);

useMount(() => {
let cancelable: ReturnType<typeof loader.init>;

if (!monaco) {
cancelable = loader.init();

void cancelable
.then((monaco) => {
setMonaco(monaco as Monaco);
return;
})
.catch((error: unknown) => {
if ((error as { type?: unknown })?.type !== 'cancelation') {
console.error('Monaco initialization: error:', error);
}
});
}

return () => cancelable?.cancel();
});

return monaco;
const isHydrated = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);

if (!isHydrated) {
return undefined;
}

return use(initMonaco());
}

function subscribe(onStoreChange: () => void): () => void {
const timeoutId = setTimeout(onStoreChange, 0);

return () => {
clearTimeout(timeoutId);
};
}

function getClientSnapshot(): boolean {
return true;
}

function getServerSnapshot(): boolean {
return false;
}

export default useMonaco;
12 changes: 12 additions & 0 deletions src/hooks/useMonaco/initMonaco.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import loader from '@willbooster/monaco-loader';

import type { Monaco } from '../..';

let monacoPromise: Promise<Monaco> | undefined;

function initMonaco(): Promise<Monaco> {
monacoPromise ??= loader.init() as Promise<Monaco>;
return monacoPromise;
}

export default initMonaco;
Loading