From 77d22713dabd43f4db93fce499ecb59743a21dd8 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 17 Apr 2026 13:47:52 +0700 Subject: [PATCH] fix(account-data-editor): load Ace before esm-resolver --- .../__tests__/account-data-editor.test.tsx | 22 +++++++- .../account-data-editor.tsx | 54 ++++++++++++------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx index 78bb4857..c25fba8d 100644 --- a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx +++ b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx @@ -58,6 +58,16 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('react-ace', async () => { const ReactModule = await vi.importActual('react'); + await Promise.resolve(); + ( + globalThis as typeof globalThis & { + ace?: { config?: { setModuleUrl?: ReturnType } }; + } + ).ace = { + config: { + setModuleUrl: vi.fn(), + }, + }; return { default: ({ value, onChange }: { value: string; onChange: (nextValue: string) => void }) => @@ -71,7 +81,13 @@ vi.mock('react-ace', async () => { vi.mock('ace-builds/src-noconflict/mode-json', () => ({})); vi.mock('ace-builds/src-noconflict/theme-monokai', () => ({})); -vi.mock('ace-builds/esm-resolver', () => ({})); +vi.mock('ace-builds/esm-resolver', () => { + if (!(globalThis as typeof globalThis & { ace?: unknown }).ace) { + throw new Error('ace is not defined'); + } + + return {}; +}); vi.mock('ace-builds/src-noconflict/worker-json?url', () => ({ default: '/worker-json.js' })); let root: Root; @@ -135,6 +151,7 @@ describe('AccountDataEditor', () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); + delete (globalThis as typeof globalThis & { ace?: unknown }).ace; AccountDataEditor = (await import('../account-data-editor')).default; testState.account = { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }; testState.alertMock.mockReset(); @@ -183,7 +200,8 @@ describe('AccountDataEditor', () => { await waitForEditor(); expect(container.textContent).not.toContain('loading_editor'); - expect(queryEditor()).toBeTruthy(); + expect(container.textContent).not.toContain('editor_fallback_warning'); + expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy(); await clickButton('return_to_settings'); diff --git a/src/views/account-data-editor/account-data-editor.tsx b/src/views/account-data-editor/account-data-editor.tsx index 18c9fe0d..028928f4 100644 --- a/src/views/account-data-editor/account-data-editor.tsx +++ b/src/views/account-data-editor/account-data-editor.tsx @@ -12,15 +12,25 @@ type AceModuleLoadResult = { onBeforeLoad: (ace: { config?: { setModuleUrl?: (name: string, value: string) => void } }) => void; }; +type EditorPhase = 'warning' | 'loading' | 'editor' | 'fallback'; + +type EditorState = { + phase: EditorPhase; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + AceEditor: React.ComponentType | null; + aceOnBeforeLoad: AceModuleLoadResult['onBeforeLoad'] | undefined; + text: string; +}; + const loadAce = async () => { - const [aceModule, workerJsonModule] = await Promise.all([ - import('react-ace'), + const aceModule = await import('react-ace'); + const [workerJsonModule] = await Promise.all([ import('ace-builds/src-noconflict/worker-json?url'), import('ace-builds/esm-resolver'), import('ace-builds/src-noconflict/mode-json'), import('ace-builds/src-noconflict/theme-monokai'), ]); - // Vite CJS interop can double-wrap the default export + // Load react-ace first so esm-resolver sees the global ace instance. const mod = aceModule.default; const Editor = typeof mod === 'function' ? mod : (mod as unknown as { default: typeof mod }).default; @@ -39,31 +49,37 @@ const AccountDataEditor = () => { const account = useAccount(); const returnTo = (location.state as { returnTo?: string } | null)?.returnTo ?? DEFAULT_RETURN_TO; - const [phase, setPhase] = useState<'warning' | 'loading' | 'editor' | 'fallback'>('warning'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [AceEditor, setAceEditor] = useState | null>(null); - const [aceOnBeforeLoad, setAceOnBeforeLoad] = useState(undefined); - const [text, setText] = useState(''); + const [{ phase, AceEditor, aceOnBeforeLoad, text }, setEditorState] = useState({ + phase: 'warning', + AceEditor: null, + aceOnBeforeLoad: undefined, + text: '', + }); useEffect(() => { if (phase !== 'loading') return; loadAce() .then(({ Editor, onBeforeLoad }) => { - setAceEditor(() => Editor); - setAceOnBeforeLoad(() => onBeforeLoad); - setText(buildEditableAccountJson(account)); - setPhase('editor'); + setEditorState({ + phase: 'editor', + AceEditor: Editor, + aceOnBeforeLoad: onBeforeLoad, + text: buildEditableAccountJson(account), + }); }) .catch(() => { - setAceOnBeforeLoad(undefined); - setText(buildEditableAccountJson(account)); - setPhase('fallback'); + setEditorState({ + phase: 'fallback', + AceEditor: null, + aceOnBeforeLoad: undefined, + text: buildEditableAccountJson(account), + }); }); }, [phase, account]); const handleGoBack = () => navigate(returnTo); - const handleContinue = () => setPhase('loading'); - const handleReset = () => setText(buildEditableAccountJson(account)); + const handleContinue = () => setEditorState((current) => ({ ...current, phase: 'loading' })); + const handleReset = () => setEditorState((current) => ({ ...current, text: buildEditableAccountJson(account) })); const handleReturn = () => navigate(returnTo); const handleSave = async () => { @@ -122,13 +138,13 @@ const AccountDataEditor = () => { fontSize={13} showPrintMargin={false} value={text} - onChange={setText} + onChange={(nextText: string) => setEditorState((current) => ({ ...current, text: nextText }))} onBeforeLoad={aceOnBeforeLoad} /> ) : (