TypeFox / monaco-languageclient

Repo hosts npm packages for monaco-languageclient, vscode-ws-jsonrpc, monaco-editor-wrapper, @typefox/monaco-editor-react and monaco-languageclient-examples
https://www.npmjs.com/package/monaco-languageclient
MIT License
1.06k stars 178 forks source link

MonacoEditorReactComp caches text and won't update it even if new text is set in userConfig #752

Open azhakhan opened 1 month ago

azhakhan commented 1 month ago

i use monaco-languageclient to render python code editor

i'm using MonacoEditorReactComp and configured it as:

here is how it looks in the code:

import '@codingame/monaco-vscode-python-default-extension';
import { UserConfig } from 'monaco-editor-wrapper';
import type { TextChanges } from '@typefox/monaco-editor-react';
import { MonacoEditorReactComp } from '@typefox/monaco-editor-react';
import { useWorkerFactory } from 'monaco-editor-wrapper/workerFactory';
import getKeybindingsServiceOverride from '@codingame/monaco-vscode-keybindings-service-override';

const useConfigureMonacoWorkers = () => {
  useWorkerFactory({
    ignoreMapping: true,
    workerLoaders: {
      editorWorkerService: () =>
        new Worker(
          new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
          { type: 'module' },
        ),
    },
  });
};

const CreateUserConfig = ({ text, uri }: { text: string; uri: string }): UserConfig => {
  return {
    languageClientConfig: {
      languageId: 'python',
      options: {
        $type: 'WebSocketUrl',
        url: LSP_URL,
        startOptions: {
          onCall: () => {},
          reportStatus: false,
        },
        stopOptions: {
          onCall: () => {},
          reportStatus: false,
        },
      },
    },
    wrapperConfig: {
      serviceConfig: {
        userServices: {
          ...getKeybindingsServiceOverride(),
        },
        debugLogging: false,
      },
      editorAppConfig: {
        $type: 'extended',
        codeResources: {
          main: { text, uri },
        },
      },
    },
  };
};

interface EditorProps {
  value: string;
  uri: string;
  onTextChanged: (textChanges: TextChanges) => void;
}

const Editor: FC<EditorProps> = ({ value, uri, onTextChanged }) => {
  const config = CreateUserConfig({ text: value, uri });

  return (
    <Stack style={{ height: '100%' }} key={value} pt={4}>
      <MonacoEditorReactComp
        key={value}
        userConfig={config}
        style={{ height: '100%' }}
        onTextChanged={onTextChanged}
      />
    </Stack>
  );
};

export const PythonEditor = () => {
  // pull code saved in file from the server
  const { code, refetch, isFetching, isLoading, isRefetching } = useFile({ ... });
  const { cwd } = useGetPage({ ... });
  // handle updates
  const [updatedCode, setUpdatedCode] = useState<string>('');
  // Create a ref to store the updatedCode
  const updatedCodeRef = useRef(updatedCode);
  updatedCodeRef.current = updatedCode;

  // sync updatedCode with server copy when a new version received 
  useEffect(() => {
    if (code) {
      setUpdatedCode(code);
    }
  }, [code]);

  ...

  const handleTextChanged = (textChanges: TextChanges) => {
    const text = textChanges.main;
    setUpdatedCode(text);
  };

  useConfigureMonacoWorkers();

  return (
    <Stack h="full" bg="white" spacing="0" w="full">
        {isFetching || isLoading || isRefetching || !cwd ? (
          <Skeleton startColor="gray.100" endColor="gray.400" h="full" />
        ) : (
          <Editor
            key={code}
            value={code}
            uri={`${cwd}/workspace/${appName}/${pageName}/${fileName}`}
            onTextChanged={handleTextChanged}
          />
        )}
      </Tabs>
    </Stack>
  );
};

besides @typefox and @codingame packages, im using chakra ui, react query, and jotai.

editor works if only editor is used.

however, if the code is updated by some other components while MonacoEditorReactComp has its own changes (unsaved or even saved), even if useFile pulls a new version of the code and passes it to MonacoEditorReactComp, MonacoEditorReactComp will not update its text content (text).

i can confirm that Editor receives a new code with value={code} and that config is contains the latest code in config.wrapperConfig.editorAppConfig.codeResources.main.text.

i tried to trigger a new render of Editor by passing new code as new key, but no luck.

it is as if MonacoEditorReactComp is cached and won’t update it even if new config received.

how can i resolve this?

thanks!

kaisalmen commented 1 month ago

Hi @azhakhan are you aware of this https://github.com/TypeFox/monaco-languageclient/blob/main/packages/examples/src/python/client/reactPython.tsx#L47-L49 This way you can get hold of the wrapper and do whatever you want afterwards. When you want to update editor text you can do this via wrapper. The react component is basically just a shell around the monaco-editor-wrapper.

azhakhan commented 1 month ago

hey @kaisalmen, thanks for the response!

i went over the methods in MonacoEditorLanguageClientWrapper and the one that seemed most relevant for my case was updateCodeResources, so i updated my code as:

            <MonacoEditorReactComp
                key={value}
                userConfig={config}
                style={{ height: '100%' }}
                onTextChanged={onTextChanged}
                onLoad={(editorWrapper: MonacoEditorLanguageClientWrapper) => {
                    editorWrapper.updateCodeResources({
                        main: { text: value, uri },
                        original: { text: value, uri },
                    });
                }}
            />

my assumption here is that i need to reset main and original to the newly received code value.

however, the issue persists.

i also tried it with async and initAndStart

            <MonacoEditorReactComp
                key={`${value}_${uri}`} // unique key based on both value and uri
                userConfig={config}
                style={{ height: '100%' }}
                onTextChanged={onTextChanged}
                onLoad={async (editorWrapper: MonacoEditorLanguageClientWrapper) => {
                    try {
                        await editorWrapper.initAndStart(
                            config,
                            document.getElementById('editorContainer'),
                        );
                        editorWrapper.updateCodeResources({
                            main: { text: value, uri },
                            original: { text: value, uri },
                        });
                    } catch (error) {
                        console.error('Failed to initialize editor wrapper:', error);
                    }
                }}
            />

but still getting caching issue.

am i missing something?

kaisalmen commented 1 month ago

@azhakhan The idea is that you store the wrapper that is passed in onLoad in your app. You don't have to call initAndStart. If you want to update the text (from your app) you just need to call updateCodeResources. onLoad let's you know the content of the editor was changed (e.g. everytime you typed something in the editor).

The latest version of the react-component (not yet released, I will publish a pre-release today or tomorrow and let you know) will perform a full re-init if an updated config is passed.

I hope this helps.

azhakhan commented 1 month ago

hey @kaisalmen, thanks for the pointer!

i’ve made the following changes:

const Editor: FC<EditorProps> = ({ value, uri, onTextChanged }) => {
    const wrapperRef = useRef<MonacoEditorLanguageClientWrapper | null>(null);
    const [isEditorLoaded, setIsEditorLoaded] = useState(false);
    const [config, setConfig] = useState(() => CreateUserConfig({ text: value, uri }));

    useEffect(() => {
        setConfig(CreateUserConfig({ text: value, uri }));
    }, [value, uri]);

    useEffect(() => {
        if (isEditorLoaded && wrapperRef.current) {
            console.log('WRAPPERREF.CURRENT', wrapperRef.current);
            wrapperRef.current.updateCodeResources({
                main: { text: value, uri },
                original: { text: value, uri },
            });
        }
    }, [isEditorLoaded, value, uri]);

    const handleLoad = useCallback(async (editorWrapper: MonacoEditorLanguageClientWrapper) => {
        console.log('EDITORWRAPPER', editorWrapper);
        wrapperRef.current = editorWrapper;
        setIsEditorLoaded(true);
    }, []);

    return (
        <Stack style={{ height: '100%' }} key={`${uri}-editor`} pt={4}>
            <MonacoEditorReactComp
                key={`${uri}-editor`}
                userConfig={config}
                style={{ height: '100%' }}
                onTextChanged={onTextChanged}
                onLoad={handleLoad}
            />
        </Stack>
    );
};

i can confirm that editorWrapper and wrapperRef.current have the same values

image

i also confirmed that wrapperRef.current has the right data in editorApp.config.codeResources.main.text

image

and the right MonacoEditorLanguageClientWrapper (id 10 in this case) is mounted to the Editor

image

however, the old value is still shown in the monaco editor (see options in Select, only shown instead of 3)

image

not sure what else to try here.

i'll wait for the latest version of the react-component and try again.

thanks again for helping me with this!

azhakhan commented 1 month ago

added a button to manually call updateCodeResources and it updates the content of the editor

    const handleClick = () => {
        wrapperRef.current.updateCodeResources({
            main: { text: 'updated code', uri },
            original: { text: 'updated code', uri },
        });
    };

    return (
        <Stack style={{ height: '100%' }} key={`${uri}-editor`} pt={4}>
            <Button onClick={handleClick} variant="outline">
                click me
            </Button>
            <MonacoEditorReactComp
                key={`${uri}-editor`} // ensure key is unique to forcefully re-render when URI changes
                userConfig={config}
                style={{ height: '100%' }}
                onTextChanged={onTextChanged}
                onLoad={handleLoad}
            />
        </Stack>
image

but the same updateCodeResources in useEffect does not work

kaisalmen commented 1 month ago

@azhakhan https://www.npmjs.com/package/@typefox/monaco-editor-react/v/6.0.0-next.1 is now available. This is a pre-release and introduces some changes to the wrapper configuration.

For reference take a look at the updated python example: https://github.com/TypeFox/monaco-languageclient/blob/main/packages/examples/src/python/client/reactPython.tsx https://github.com/TypeFox/monaco-languageclient/blob/main/packages/examples/src/python/client/config.ts

azhakhan commented 1 month ago

hey @kaisalmen, thanks for the update!

unfortunately im still running into the same issues

i tried updating to 6.0.0-next.1 version. i’ve updated the following in package.json:

    "@typefox/monaco-editor-react": "6.0.0-next.1",
    "monaco-editor-wrapper": "6.0.0-next.1",
    "monaco-languageclient": "9.0.0-next.1",
    "vscode-languageclient": "~9.0.1",
    "@codingame/monaco-vscode-keybindings-service-override": "^9.0.3",
    "@codingame/monaco-vscode-python-default-extension": "^9.0.3",

i’m using python-lsp-server for lsp, so here is how i setup config:

export const createUserConfig = (
  workspaceRoot: string,
  code: string,
  codeUri: string,
): WrapperConfig => {
  const url = createUrl({ url: LSP_URL });
  const webSocket = new WebSocket(url);
  const iWebSocket = toSocket(webSocket);
  const reader = new WebSocketMessageReader(iWebSocket);
  const writer = new WebSocketMessageWriter(iWebSocket);

  return {
    languageClientConfigs: {
      python: {
        languageId: 'python',
        name: 'Python Language Server',
        connection: {
          options: {
            $type: 'WebSocketDirect',
            webSocket,
          },
          messageTransports: { reader, writer },
        },
        clientOptions: {
          documentSelector: ['python'],
          workspaceFolder: {
            index: 0,
            name: 'workspace',
            uri: vscode.Uri.parse(workspaceRoot),
          },
        },
      },
    },

    // logLevel: LogLevel.Info,
    serviceConfig: {
      userServices: {
        ...getEditorServiceOverride(useOpenEditorStub),
        ...getKeybindingsServiceOverride(),
      },
    },
    editorAppConfig: {
      $type: 'extended',
      codeResources: {
        main: {
          text: code,
          uri: codeUri,
        },
        original: {
          text: code,
          uri: codeUri,
        },
      },
      userConfiguration: {
        json: JSON.stringify({
          'workbench.colorTheme': 'Default Light Modern',
          'editor.guides.bracketPairsHorizontal': 'active',
          'editor.wordBasedSuggestions': 'off',
          'editor.experimental.asyncTokenization': true,
        }),
      },
      useDiffEditor: false,
      monacoWorkerFactory: configureMonacoWorkers,
    },
  };
};

to be honest, i don’t fully understand what messageTransports: { reader, writer } is for, but it’s not breaking anything, so i kept it

and here is how i initiate MonacoEditorReactComp

export const FunctionEditor = () => {
  const [fileName, setSelectedFile] = useState('main.py');
  const { code, refetch, isFetching, isLoading, isRefetching } = useFile();
  const { cwd } = useGetPage();

  const workspaceRoot = `${cwd}/workspace/${appName}/${pageName}`;
  const codeUri = `${workspaceRoot}/${fileName}`;

  const wrapperRef = useRef<MonacoEditorLanguageClientWrapper | null>(null);
  const [tabIndex, setTabIndex] = useState(0);

  const codeRef = useRef(code);
  codeRef.current = code;

  useEffect(() => {
    if (wrapperRef.current) {
      wrapperRef.current.updateCodeResources({
        main: { text: code, uri: codeUri },
        original: { text: code, uri: codeUri },
      });
    }
  }, [code, codeUri]);

...

  const handleSave = useCallback(() => {
    const codeValue = wrapperRef.current?.getTextContents()?.text || '';
    savePythonMutation.mutate({ pageName, appName, fileName, code: codeValue });
  }, [appName, pageName, fileName, savePythonMutation]);

...

  const memoizedWrapperConfig = useMemo(
    () => createUserConfig(workspaceRoot, code, codeUri),
    [workspaceRoot, code, codeUri],
  );

  const handleLoad = useCallback(async (editorWrapper: MonacoEditorLanguageClientWrapper) => {
    wrapperRef.current = editorWrapper;
  }, []);

  return (
    <Stack h="full" bg="white" spacing="0" w="full">
      <Tabs>
        <TabList mt={2} px={4}>
          <Tab>main.py</Tab>
          <Tab>functions.py</Tab>
          <Spacer />
        </TabList>
        <TabPanels h="calc(100% - 50px)" w="full">

          <MonacoEditorReactComp
            style={{ height: '100%' }}
            wrapperConfig={memoizedWrapperConfig}
            onLoad={handleLoad}
          />

        </TabPanels>
      </Tabs>
    </Stack>
  );
};

i decided to ditch onTextChanged for edits tracking and instead access updated code directly via wrapperRef.current?.getTextContents()?.text

but im still running into the same issues as before:

when we the code is updated from outside of monaco and updates are passed to the editor via updateCodeResources, those changes are not taking place. i confirmed that updateCodeResources is called from within useEffect, but it seems like something else is overwriting the content of the editor from within MonacoEditorReactComp

before when i had onTextChanged implemented, i saw how right after updateCodeResources was called, onTextChanged was also called but with an old code.

also, because i need to edit 2 files (main.py and functions.py), i need 2 monaco editors. but if i keep code and uri in createUserConfig, a new web socket is opened every time i switch between editors. but if i initiate wrapperConfig without code and uri and instead set them via updateCodeResources after MonacoEditorReactComp is initiated, no code is displayed in the editor

kaisalmen commented 1 month ago

@azhakhan can you share a reproducible example in a repo or can you augment a react example in this repo so your problem can be reproduced? Thank you.