mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
01a01e2951
commit
6a71cf8586
24 changed files with 150 additions and 104 deletions
|
|
@ -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__/**"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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's{" "}
|
||||
Move your API keys from <code className={"tw-text-muted tw-bg-muted/10"}>data.json</code> to
|
||||
this device'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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
28
src/utils/react/createPluginRoot.tsx
Normal file
28
src/utils/react/createPluginRoot.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue