chore(react): centralize React root creation via createPluginRoot helper (#2467)

* chore(react): centralize React root creation via createPluginRoot helper

Every standalone React root in the plugin must wrap its tree in
AppContext.Provider so descendants can rely on useApp() — PR #2466 fixed
one missing wrap (QuickAskOverlay) but the contract was implicit and any
new createRoot callsite could silently re-introduce the same crash.
Introduce a createPluginRoot(container, app) helper that injects the
provider automatically, migrate all 17 createRoot callsites to use it,
and add a Jest guardrail that fails if any non-helper file imports
createRoot from react-dom/client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): replace createRoot Jest guardrail with ESLint rule

Switch the createPluginRoot enforcement from a Jest test (walks src/
and greps imports) to an ESLint no-restricted-syntax rule. Same
invariant, faster feedback (in-editor instead of on test run), and one
fewer test file to maintain. The helper file is exempted via the
config block's `ignores`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-15 11:49:03 -07:00 committed by GitHub
parent 01a01e2951
commit 6a71cf8586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 150 additions and 104 deletions

View file

@ -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 <AppContext.Provider> 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__/**"],

View file

@ -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;

View file

@ -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);

View file

@ -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<void> {
this.root = createRoot(this.containerEl.children[1]);
this.root = createPluginRoot(this.containerEl.children[1], this.app);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
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(
<AppContext.Provider value={this.app}>
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
</AppContext.Provider>
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
);
}

View file

@ -747,6 +747,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}
const rootRecord = ensureToolCallRoot(
app,
messageId.current,
rootsRef.current,
toolCallId,
@ -781,6 +782,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
// Use dedicated error block root to prevent ID collisions with tool calls
const rootRecord = ensureErrorBlockRoot(
app,
messageId.current,
errorRootsRef.current,
errorId,

View file

@ -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<string, ToolCallRootRecord>,
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<string, ToolCallRootRecord>,
errorId: string,
@ -247,7 +251,7 @@ export const ensureErrorBlockRoot = (
if (!record) {
record = {
root: createRoot(container),
root: createPluginRoot(container, app),
isUnmounting: false,
container,
};

View file

@ -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<typeof createRoot> | null = null;
private root: ReturnType<typeof createPluginRoot> | 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

View file

@ -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;

View file

@ -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();

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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
</div>
<p className="tw-m-0 tw-text-muted">
Move your API keys from <code className={"tw-text-muted tw-bg-muted/10"}>data.json</code>{" "}
to this device&apos;s{" "}
Move your API keys from <code className={"tw-text-muted tw-bg-muted/10"}>data.json</code> to
this device&apos;s{" "}
<code className={"tw-text-accent tw-bg-muted/10"}>Obsidian Keychain</code>.{" "}
<code>data.json</code> will be stripped of API keys after migration.
</p>
@ -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;

View file

@ -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();

View file

@ -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(
<AppContext.Provider value={this.app}>
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
</AppContext.Provider>
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
);
}

View file

@ -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]");

View file

@ -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(
<AppContext.Provider value={this.options.plugin.app}>
<QuickAskPanel
plugin={this.options.plugin}
editor={this.options.editor}
view={this.options.view}
selectedText={this.options.selectedText}
replaceGuard={this.options.replaceGuard}
onClose={this.closeWithAnimation}
onDragOffset={this.handleDragOffset}
onResizeStart={this.handleResizeStart}
hasCustomHeight={this.hasUserResizedHeight}
/>
</AppContext.Provider>
<QuickAskPanel
plugin={this.options.plugin}
editor={this.options.editor}
view={this.options.view}
selectedText={this.options.selectedText}
replaceGuard={this.options.replaceGuard}
onClose={this.closeWithAnimation}
onDragOffset={this.handleDragOffset}
onResizeStart={this.handleResizeStart}
hasCustomHeight={this.hasUserResizedHeight}
/>
);
}
@ -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

View file

@ -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,

View file

@ -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 () => {

View file

@ -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(
<AppContext.Provider value={this.app}>
<SettingsMainV2 plugin={this.plugin} />
</AppContext.Provider>
);
sections.render(<SettingsMainV2 plugin={this.plugin} />);
}
}

View file

@ -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(
<ApiKeyModalContent onClose={() => this.close()} onGoToModelTab={this.onGoToModelTab} />

View file

@ -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,

View file

@ -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();

View file

@ -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(<AppContext.Provider value={app}>{children}</AppContext.Provider>);
},
unmount() {
root.unmount();
},
};
}