diff --git a/eslint.config.mjs b/eslint.config.mjs index e9a1993f..61d6ed85 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,26 @@ export default [ }, }, + // Guardrail: every standalone React root in the plugin must go through + // `createPluginRoot` so descendants can rely on `useApp()` unconditionally + // (the bug class fixed in PR #2466). Forbid importing `createRoot` from + // `react-dom/client` anywhere except the helper itself. + { + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/utils/react/createPluginRoot.tsx"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']", + message: + "Use createPluginRoot from '@/utils/react/createPluginRoot' instead. It wraps the root in so descendants can rely on useApp() unconditionally (see PR #2466).", + }, + ], + }, + }, + // Test files need Jest globals { files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"], diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index 60ad3e1f..c8a63b60 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -16,7 +16,8 @@ import { PenLine } from "lucide-react"; import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { preprocessAIResponse } from "@/utils/markdownPreprocess"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { CustomCommand } from "@/commands/type"; import { useSettingsValue, updateSetting } from "@/settings/model"; import { @@ -650,7 +651,7 @@ export class CustomCommandChatModal { this.container.className = "copilot-menu-command-modal-container"; doc.body.appendChild(this.container); - this.root = createRoot(this.container); + this.root = createPluginRoot(this.container, this.app); // Capture ReplaceGuard (replaces captureReplaceSnapshot) const { selectedText, command, systemPrompt, behaviorConfig } = this.configs; diff --git a/src/commands/CustomCommandSettingsModal.tsx b/src/commands/CustomCommandSettingsModal.tsx index fdde77b7..91b4000a 100644 --- a/src/commands/CustomCommandSettingsModal.tsx +++ b/src/commands/CustomCommandSettingsModal.tsx @@ -5,7 +5,8 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import React, { useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; import { getModelDisplayText } from "@/components/ui/model-display"; import { cn } from "@/lib/utils"; @@ -191,7 +192,7 @@ export class CustomCommandSettingsModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = (command: CustomCommand) => { void this.onUpdate(command); diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index 5d40c989..7764df34 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -2,13 +2,14 @@ import ChainManager from "@/LLMProviders/chainManager"; import Chat from "@/components/Chat"; import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout"; import { CHAT_VIEWTYPE } from "@/constants"; -import { AppContext, EventTargetContext } from "@/context"; +import { EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; import { FileParserManager } from "@/tools/FileParserManager"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import * as Tooltip from "@radix-ui/react-tooltip"; import { ItemView, Platform, WorkspaceLeaf } from "obsidian"; import * as React from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; export default class CopilotView extends ItemView { private get chainManager(): ChainManager { @@ -55,7 +56,7 @@ export default class CopilotView extends ItemView { } async onOpen(): Promise { - this.root = createRoot(this.containerEl.children[1]); + this.root = createPluginRoot(this.containerEl.children[1], this.app); const handleSaveAsNote = (saveFunction: () => Promise) => { this.handleSaveAsNote = saveFunction; }; @@ -77,7 +78,7 @@ export default class CopilotView extends ItemView { this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => { if (!this.root) return; this.root.unmount(); - this.root = createRoot(this.containerEl.children[1]); + this.root = createPluginRoot(this.containerEl.children[1], this.app); this.renderView(handleSaveAsNote, updateUserMessageHistory); }); @@ -182,20 +183,18 @@ export default class CopilotView extends ItemView { if (!this.root) return; this.root.render( - - - - - - - + + + + + ); } diff --git a/src/components/chat-components/ChatSingleMessage.tsx b/src/components/chat-components/ChatSingleMessage.tsx index 9b636344..0398f01b 100644 --- a/src/components/chat-components/ChatSingleMessage.tsx +++ b/src/components/chat-components/ChatSingleMessage.tsx @@ -747,6 +747,7 @@ const ChatSingleMessage: React.FC = ({ } const rootRecord = ensureToolCallRoot( + app, messageId.current, rootsRef.current, toolCallId, @@ -781,6 +782,7 @@ const ChatSingleMessage: React.FC = ({ // Use dedicated error block root to prevent ID collisions with tool calls const rootRecord = ensureErrorBlockRoot( + app, messageId.current, errorRootsRef.current, errorId, diff --git a/src/components/chat-components/toolCallRootManager.tsx b/src/components/chat-components/toolCallRootManager.tsx index 05ee62ef..6bb586bf 100644 --- a/src/components/chat-components/toolCallRootManager.tsx +++ b/src/components/chat-components/toolCallRootManager.tsx @@ -1,10 +1,12 @@ import React from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; import { ErrorBlock } from "@/components/chat-components/ErrorBlock"; import { ToolCallBanner } from "@/components/chat-components/ToolCallBanner"; import type { ErrorMarker, ToolCallMarker } from "@/LLMProviders/chainRunner/utils/toolCallParser"; import { logWarn } from "@/logger"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import type { App } from "obsidian"; declare global { interface Window { @@ -156,6 +158,7 @@ const scheduleToolCallRootDisposal = ( * Ensure a React root exists for the provided tool call container and return the root record. */ export const ensureToolCallRoot = ( + app: App, messageId: string, messageRoots: Map, toolCallId: string, @@ -194,7 +197,7 @@ export const ensureToolCallRoot = ( if (!record) { record = { - root: createRoot(container), + root: createPluginRoot(container, app), isUnmounting: false, container, }; @@ -210,6 +213,7 @@ export const ensureToolCallRoot = ( * Uses a separate registry from tool calls to prevent ID collisions and race conditions. */ export const ensureErrorBlockRoot = ( + app: App, messageId: string, messageRoots: Map, errorId: string, @@ -247,7 +251,7 @@ export const ensureErrorBlockRoot = ( if (!record) { record = { - root: createRoot(container), + root: createPluginRoot(container, app), isUnmounting: false, container, }; diff --git a/src/components/composer/ApplyView.tsx b/src/components/composer/ApplyView.tsx index d153c600..6ac3ada1 100644 --- a/src/components/composer/ApplyView.tsx +++ b/src/components/composer/ApplyView.tsx @@ -1,11 +1,11 @@ import { cn } from "@/lib/utils"; import { logError } from "@/logger"; import { getSettings, updateSetting } from "@/settings/model"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { Change, diffArrays } from "diff"; import { Check, X as XIcon } from "lucide-react"; import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian"; import React, { memo, useMemo, useRef, useState } from "react"; -import { createRoot } from "react-dom/client"; import { Button } from "../ui/button"; import { SettingSwitch } from "../ui/setting-switch"; import { getChangeBlocks } from "@/composerUtils"; @@ -208,7 +208,7 @@ interface ExtendedChange extends Change { } export class ApplyView extends ItemView { - private root: ReturnType | null = null; + private root: ReturnType | null = null; private state: ApplyViewState | null = null; private result: ApplyViewResult | null = null; @@ -252,7 +252,7 @@ export class ApplyView extends ItemView { const rootEl = contentEl.createDiv(); if (!this.root) { - this.root = createRoot(rootEl); + this.root = createPluginRoot(rootEl, this.app); } // Pass a close function that takes a result diff --git a/src/components/modals/ConfirmModal.tsx b/src/components/modals/ConfirmModal.tsx index ed5752fc..f8085ae3 100644 --- a/src/components/modals/ConfirmModal.tsx +++ b/src/components/modals/ConfirmModal.tsx @@ -1,8 +1,9 @@ import { Button } from "@/components/ui/button"; import { logError } from "@/logger"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { App, Modal } from "obsidian"; import React from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; function ConfirmModalContent({ content, @@ -57,7 +58,7 @@ export class ConfirmModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = () => { this.confirmed = true; diff --git a/src/components/modals/CopilotPlusExpiredModal.tsx b/src/components/modals/CopilotPlusExpiredModal.tsx index 4c9e9644..5be79721 100644 --- a/src/components/modals/CopilotPlusExpiredModal.tsx +++ b/src/components/modals/CopilotPlusExpiredModal.tsx @@ -1,12 +1,12 @@ import React from "react"; import { App, Modal } from "obsidian"; -import { createRoot } from "react-dom/client"; import { Root } from "react-dom/client"; import { Button } from "@/components/ui/button"; import { isPlusModel, navigateToPlusPage } from "@/plusUtils"; import { PLUS_UTM_MEDIUMS } from "@/constants"; import { ExternalLink } from "lucide-react"; import { getSettings } from "@/settings/model"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; function CopilotPlusExpiredModalContent({ onCancel }: { onCancel: () => void }) { const settings = getSettings(); @@ -56,7 +56,7 @@ export class CopilotPlusExpiredModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleCancel = () => { this.close(); diff --git a/src/components/modals/CopilotPlusWelcomeModal.tsx b/src/components/modals/CopilotPlusWelcomeModal.tsx index bf79352b..48013a31 100644 --- a/src/components/modals/CopilotPlusWelcomeModal.tsx +++ b/src/components/modals/CopilotPlusWelcomeModal.tsx @@ -1,8 +1,8 @@ import React from "react"; import { App, Modal } from "obsidian"; -import { createRoot } from "react-dom/client"; import { Root } from "react-dom/client"; import { Button } from "@/components/ui/button"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { DEFAULT_COPILOT_PLUS_CHAT_MODEL, DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL, @@ -77,7 +77,7 @@ export class CopilotPlusWelcomeModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = () => { applyPlusSettings(); diff --git a/src/components/modals/CustomPatternInputModal.tsx b/src/components/modals/CustomPatternInputModal.tsx index f321009f..d91ad922 100644 --- a/src/components/modals/CustomPatternInputModal.tsx +++ b/src/components/modals/CustomPatternInputModal.tsx @@ -1,8 +1,9 @@ import { App, Modal } from "obsidian"; import React, { useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; function CustomPatternInputModalContent({ onConfirm, @@ -61,7 +62,7 @@ export class CustomPatternInputModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = (extension: string) => { this.onConfirm(extension); diff --git a/src/components/modals/ExtensionInputModal.tsx b/src/components/modals/ExtensionInputModal.tsx index 316dbcf9..a9074264 100644 --- a/src/components/modals/ExtensionInputModal.tsx +++ b/src/components/modals/ExtensionInputModal.tsx @@ -1,8 +1,9 @@ import { App, Modal } from "obsidian"; import React, { useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; function ExtensionInputModalContent({ onConfirm, @@ -72,7 +73,7 @@ export class ExtensionInputModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = (extension: string) => { this.onConfirm(extension); diff --git a/src/components/modals/MigrateConfirmModal.tsx b/src/components/modals/MigrateConfirmModal.tsx index 427af3fb..1b4e70cf 100644 --- a/src/components/modals/MigrateConfirmModal.tsx +++ b/src/components/modals/MigrateConfirmModal.tsx @@ -28,10 +28,11 @@ */ import { Button } from "@/components/ui/button"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { Info, ShieldCheck, Smartphone } from "lucide-react"; import { App, Modal } from "obsidian"; import React, { useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; interface MigrateConfirmContentProps { onConfirm: () => void; @@ -53,8 +54,8 @@ function MigrateConfirmContent({ onConfirm, onCancel }: MigrateConfirmContentPro

- Move your API keys from data.json{" "} - to this device's{" "} + Move your API keys from data.json to + this device's{" "} Obsidian Keychain.{" "} data.json will be stripped of API keys after migration.

@@ -131,7 +132,7 @@ export class MigrateConfirmModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = () => { this.confirmed = true; diff --git a/src/components/modals/YoutubeTranscriptModal.tsx b/src/components/modals/YoutubeTranscriptModal.tsx index 401f4b85..cccc8a95 100644 --- a/src/components/modals/YoutubeTranscriptModal.tsx +++ b/src/components/modals/YoutubeTranscriptModal.tsx @@ -3,9 +3,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { logError } from "@/logger"; import { formatYoutubeUrl, insertIntoEditor, validateYoutubeUrl } from "@/utils"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { App, Modal, Notice } from "obsidian"; import * as React from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; interface TranscriptData { videoId: string; @@ -214,7 +215,7 @@ export class YoutubeTranscriptModal extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleClose = () => { this.close(); diff --git a/src/components/modals/project/AddProjectModal.tsx b/src/components/modals/project/AddProjectModal.tsx index 98c0f7ec..3a190ccc 100644 --- a/src/components/modals/project/AddProjectModal.tsx +++ b/src/components/modals/project/AddProjectModal.tsx @@ -1,5 +1,5 @@ import { ProjectConfig } from "@/aiParams"; -import { AppContext, useApp } from "@/context"; +import { useApp } from "@/context"; import { ContextManageModal } from "@/components/modals/project/context-manage-modal"; import { openCachedItemPreview } from "@/utils/cacheFileOpener"; import type { ProcessingItem } from "@/components/project/processingAdapter"; @@ -23,9 +23,10 @@ import { checkModelApiKey, err2String, randomUUID } from "@/utils"; import { Settings } from "lucide-react"; import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils"; import type CopilotPlugin from "@/main"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { App, Modal, Notice } from "obsidian"; import React, { useMemo, useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; interface AddProjectModalContentProps { initialProject?: ProjectConfig; @@ -471,7 +472,7 @@ export class AddProjectModal extends Modal { // Reason: Ensure the modal is wide enough for card layout and tall enough for ScrollArea modalEl.addClass("!tw-max-h-[85vh]"); - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleSave = async (project: ProjectConfig) => { await this.onSave(project); @@ -483,14 +484,12 @@ export class AddProjectModal extends Modal { }; this.root.render( - - - + ); } diff --git a/src/components/modals/project/context-manage-modal.tsx b/src/components/modals/project/context-manage-modal.tsx index b39cb609..5f1fb896 100644 --- a/src/components/modals/project/context-manage-modal.tsx +++ b/src/components/modals/project/context-manage-modal.tsx @@ -38,8 +38,9 @@ import { } from "lucide-react"; import { App, Modal, Notice, Platform, TFile } from "obsidian"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; import { HelpTooltip } from "@/components/ui/help-tooltip"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; function FileIcon({ extension, size = "tw-size-4" }: { extension: string; size?: string }) { const ext = extension.toLowerCase().replace("*.", ""); @@ -1380,7 +1381,7 @@ export class ContextManageModal extends Modal { onOpen() { const { contentEl, modalEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); modalEl.addClass("tw-min-w-[50vw]"); diff --git a/src/components/quick-ask/QuickAskOverlay.tsx b/src/components/quick-ask/QuickAskOverlay.tsx index 03058ee7..a2571eac 100644 --- a/src/components/quick-ask/QuickAskOverlay.tsx +++ b/src/components/quick-ask/QuickAskOverlay.tsx @@ -9,10 +9,10 @@ import { EditorView } from "@codemirror/view"; import type { Editor } from "obsidian"; import React from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; import { updateDynamicStyleClass, clearDynamicStyleClass } from "@/utils/dom/dynamicStyleManager"; import { QuickAskPanel } from "./QuickAskPanel"; -import { AppContext } from "@/context"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import type CopilotPlugin from "@/main"; import type { ReplaceGuard } from "@/editor/replaceGuard"; import type { ResizeDirection } from "@/hooks/use-resizable"; @@ -240,19 +240,17 @@ export class QuickAskOverlay { } this.root.render( - - - + ); } @@ -341,7 +339,7 @@ export class QuickAskOverlay { overlayRoot.appendChild(overlayContainer); this.overlayContainer = overlayContainer; - this.root = createRoot(overlayContainer); + this.root = createPluginRoot(overlayContainer, this.options.plugin.app); this.renderPanel(); // Reason: Reset side lock on scroll/resize so placement is re-evaluated diff --git a/src/encryptionService.test.ts b/src/encryptionService.test.ts index 78b6046b..80c3820e 100644 --- a/src/encryptionService.test.ts +++ b/src/encryptionService.test.ts @@ -62,7 +62,7 @@ const testLocalStorage = ensureTestLocalStorage(); let randomCounter = 1; Object.defineProperty(window.crypto, "getRandomValues", { value: jest.fn((arr: Uint8Array) => { - for (let i = 0; i < arr.length; i++) arr[i] = (randomCounter++ & 0xff); + for (let i = 0; i < arr.length; i++) arr[i] = randomCounter++ & 0xff; return arr; }), configurable: true, diff --git a/src/services/settingsPersistence.test.ts b/src/services/settingsPersistence.test.ts index 5d7e653c..460e9201 100644 --- a/src/services/settingsPersistence.test.ts +++ b/src/services/settingsPersistence.test.ts @@ -829,11 +829,7 @@ describe("migrateDiskSecretsToKeychain", () => { keychain.setSecretById.mockReset(); keychain.setSecretById.mockReturnValue(undefined); const cleanSave = jest.fn().mockResolvedValue(undefined); - await mod.persistSettings( - mockSettings.current, - cleanSave, - mockSettings.current - ); + await mod.persistSettings(mockSettings.current, cleanSave, mockSettings.current); await mod.flushPersistence(); // Step 3: the lock must be lifted so the next migration attempt can run. @@ -867,9 +863,9 @@ describe("migrateDiskSecretsToKeychain", () => { }); const saveData = jest.fn().mockResolvedValue(undefined); - await expect( - mod.persistSettings(mockSettings.current, saveData) - ).rejects.toThrow(/undecryptable secrets/); + await expect(mod.persistSettings(mockSettings.current, saveData)).rejects.toThrow( + /undecryptable secrets/ + ); await mod.flushPersistence(); expect(keychain.setSecretById).not.toHaveBeenCalled(); @@ -878,9 +874,7 @@ describe("migrateDiskSecretsToKeychain", () => { // canClearDiskSecrets returns false here for a different reason // (isKeychainOnly), so we test the lock directly by reading the exposed // helper through a disk-mode probe. - expect( - mod.canClearDiskSecrets(makeSettings({ openAIApiKey: "sk-live" })) - ).toBe(true); + expect(mod.canClearDiskSecrets(makeSettings({ openAIApiKey: "sk-live" }))).toBe(true); }); }); @@ -934,11 +928,7 @@ describe("canClearDiskSecrets", () => { jest.fn().mockResolvedValue(undefined) ); - expect( - mod.canClearDiskSecrets( - makeSettings({ _keychainOnly: true }) - ) - ).toBe(false); + expect(mod.canClearDiskSecrets(makeSettings({ _keychainOnly: true }))).toBe(false); }); it("returns false when keychain is unavailable", async () => { diff --git a/src/settings/SettingsPage.tsx b/src/settings/SettingsPage.tsx index 813b4675..cf6c9edc 100644 --- a/src/settings/SettingsPage.tsx +++ b/src/settings/SettingsPage.tsx @@ -5,9 +5,8 @@ import { getSettings } from "@/settings/model"; import { logInfo, logError } from "@/logger"; import { App, Notice, PluginSettingTab } from "obsidian"; import React from "react"; -import { createRoot } from "react-dom/client"; import SettingsMainV2 from "@/settings/v2/SettingsMainV2"; -import { AppContext } from "@/context"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; export class CopilotSettingTab extends PluginSettingTab { plugin: CopilotPlugin; @@ -66,12 +65,8 @@ export class CopilotSettingTab extends PluginSettingTab { containerEl.empty(); containerEl.addClass("tw-select-text"); const div = containerEl.createDiv("div"); - const sections = createRoot(div); + const sections = createPluginRoot(div, this.app); - sections.render( - - - - ); + sections.render(); } } diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index 3f734a7d..fb60095d 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -11,7 +11,8 @@ import { ChevronDown, ChevronRight, ChevronUp, Info } from "lucide-react"; import { getApiKeyForProvider } from "@/utils/modelUtils"; import { App, Modal } from "obsidian"; import React, { useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; interface ApiKeyModalContentProps { onClose: () => void; @@ -183,7 +184,7 @@ export class ApiKeyDialog extends Modal { onOpen() { const { contentEl } = this; - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); this.root.render( this.close()} onGoToModelTab={this.onGoToModelTab} /> diff --git a/src/settings/v2/components/ModelEditDialog.tsx b/src/settings/v2/components/ModelEditDialog.tsx index db6c9ef4..bea5f7db 100644 --- a/src/settings/v2/components/ModelEditDialog.tsx +++ b/src/settings/v2/components/ModelEditDialog.tsx @@ -20,7 +20,8 @@ import { debounce, getProviderInfo, getProviderLabel } from "@/utils"; import { getApiKeyForProvider } from "@/utils/modelUtils"; import { App, Modal, Platform } from "obsidian"; import React, { useCallback, useMemo, useState } from "react"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor"; interface ModelEditModalContentProps { @@ -368,7 +369,7 @@ export class ModelEditModal extends Modal { if (Platform.isMobile) { modalEl.addClass("tw-h-4/5"); } - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleUpdate = ( isEmbeddingModel: boolean, diff --git a/src/system-prompts/SystemPromptAddModal.tsx b/src/system-prompts/SystemPromptAddModal.tsx index 4444987b..30a7695c 100644 --- a/src/system-prompts/SystemPromptAddModal.tsx +++ b/src/system-prompts/SystemPromptAddModal.tsx @@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Lightbulb } from "lucide-react"; import { App, Modal, Notice, Platform } from "obsidian"; -import { createRoot, Root } from "react-dom/client"; +import { Root } from "react-dom/client"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { UserSystemPrompt } from "@/system-prompts/type"; import { validatePromptName } from "@/system-prompts/systemPromptUtils"; import { SystemPromptManager } from "@/system-prompts/systemPromptManager"; @@ -239,7 +240,7 @@ export class SystemPromptAddModal extends Modal { modalEl.addClass("tw-h-4/5"); } - this.root = createRoot(contentEl); + this.root = createPluginRoot(contentEl, this.app); const handleConfirm = async (prompt: UserSystemPrompt) => { const now = Date.now(); diff --git a/src/utils/react/createPluginRoot.tsx b/src/utils/react/createPluginRoot.tsx new file mode 100644 index 00000000..151e3ad3 --- /dev/null +++ b/src/utils/react/createPluginRoot.tsx @@ -0,0 +1,28 @@ +import { AppContext } from "@/context"; +import { App } from "obsidian"; +import React from "react"; +import { createRoot, Root } from "react-dom/client"; + +/** + * Create a React root that always provides the Obsidian {@link App} via + * {@link AppContext}. + * + * Every standalone React root in the plugin (overlays, modals, item views, + * setting tabs) must use this helper instead of calling `createRoot` + * directly so descendants can rely on `useApp()` unconditionally. A static + * Jest guardrail (`createPluginRoot.test.ts`) enforces this rule. + * + * The returned object matches React's {@link Root} interface, so callers + * can treat it as a drop-in replacement. + */ +export function createPluginRoot(container: Element | DocumentFragment, app: App): Root { + const root = createRoot(container); + return { + render(children) { + root.render({children}); + }, + unmount() { + root.unmount(); + }, + }; +}