diff --git a/README.md b/README.md index 8b92c55..ae63faa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/e2e/next-app/app/editorProbe.tsx b/e2e/next-app/app/editorProbe.tsx index 3d9268d..f465055 100644 --- a/e2e/next-app/app/editorProbe.tsx +++ b/e2e/next-app/app/editorProbe.tsx @@ -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 ( <> -

{loadedMonaco?.editor ? 'hook-ok' : 'hook-pending'}

+ hook-pending

}> + +
{editorStatus}
); } + +function HookStatus() { + const loadedMonaco = useMonaco(); + + if (!loadedMonaco) { + return

hook-pending

; + } + + return

{loadedMonaco.editor ? 'hook-ok' : 'hook-mismatch'}

; +} diff --git a/src/DiffEditor/DiffEditor.tsx b/src/DiffEditor/DiffEditor.tsx index 1ef471c..bbb2733 100644 --- a/src/DiffEditor/DiffEditor.tsx +++ b/src/DiffEditor/DiffEditor.tsx @@ -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 ( + + } + > + + + ); +} + +function DiffEditorContent(props: DiffEditorProps): ReactElement { + const monaco = useMonaco(); + + if (!monaco) { + const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props; + + return ( + + ); + } + + return ; +} + +function MountedDiffEditor({ + monaco, original, modified, language, @@ -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(null); - const monacoRef = useRef(null); + const monacoRef = useRef(monaco); const containerRef = useRef(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( @@ -149,7 +191,6 @@ function DiffEditor({ ); const setModels = useCallback(() => { - if (!monacoRef.current) return; beforeMountRef.current(monacoRef.current); const originalModel = getOrCreateModel( monacoRef.current, @@ -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; diff --git a/src/Editor/Editor.tsx b/src/Editor/Editor.tsx index a0bb20d..f30d8f2 100644 --- a/src/Editor/Editor.tsx +++ b/src/Editor/Editor.tsx @@ -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'; @@ -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 ( + + } + > + + + ); +} + +function EditorContent(props: EditorProps): ReactElement { + const monaco = useMonaco(); + + if (!monaco) { + const { width = '100%', height = '100%', loading = 'Loading...', className, wrapperProps = {} } = props; + + return ( + + ); + } + + return ; +} + +function MountedEditor({ + monaco, defaultValue, defaultLanguage, defaultPath, @@ -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(null); + const monacoRef = useRef(monaco); const editorRef = useRef(undefined); const containerRef = useRef(null); const onMountRef = useRef(onMount); @@ -53,14 +95,14 @@ function Editor({ const preventCreation = useRef(false); const preventTriggerChangeEvent = useRef(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( @@ -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; @@ -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 diff --git a/src/MonacoContainer/types.ts b/src/MonacoContainer/types.ts index 863e3e9..1ac2cb6 100644 --- a/src/MonacoContainer/types.ts +++ b/src/MonacoContainer/types.ts @@ -5,7 +5,7 @@ export interface ContainerProps { height: number | string; isEditorReady: boolean; loading: ReactNode | string; - _ref: RefObject; + _ref?: RefObject; className?: string; wrapperProps?: object; } diff --git a/src/hooks/useMonaco/index.ts b/src/hooks/useMonaco/index.ts index 977b392..81bb95f 100644 --- a/src/hooks/useMonaco/index.ts +++ b/src/hooks/useMonaco/index.ts @@ -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(loader.__getMonacoInstance() as Monaco | undefined); - - useMount(() => { - let cancelable: ReturnType; - - 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; diff --git a/src/hooks/useMonaco/initMonaco.ts b/src/hooks/useMonaco/initMonaco.ts new file mode 100644 index 0000000..fe29fc8 --- /dev/null +++ b/src/hooks/useMonaco/initMonaco.ts @@ -0,0 +1,12 @@ +import loader from '@willbooster/monaco-loader'; + +import type { Monaco } from '../..'; + +let monacoPromise: Promise | undefined; + +function initMonaco(): Promise { + monacoPromise ??= loader.init() as Promise; + return monacoPromise; +} + +export default initMonaco;