Skip to content

Comments

ENG-1456: Migrate personal boolean flag consumers (getSetting → getPersonalSetting)#814

Open
sid597 wants to merge 1 commit intoeng-1472-refactor-blockpropsettingpanels-to-add-accessor-backedfrom
eng-1456-migrate-personal-boolean-flag-consumers-getsetting
Open

ENG-1456: Migrate personal boolean flag consumers (getSetting → getPersonalSetting)#814
sid597 wants to merge 1 commit intoeng-1472-refactor-blockpropsettingpanels-to-add-accessor-backedfrom
eng-1456-migrate-personal-boolean-flag-consumers-getsetting

Conversation

@sid597
Copy link
Collaborator

@sid597 sid597 commented Feb 22, 2026


Open with Devin

Summary by CodeRabbit

  • Refactor
    • Migrated all user-configurable settings from global application storage to per-user personal settings. Individual preferences for feedback visibility, canvas overlays, keyboard shortcuts, diagnostic tracking, sidebar behavior, query metadata display, and styling options are now preserved uniquely for each user rather than being shared globally.

@linear
Copy link

linear bot commented Feb 22, 2026

@supabase
Copy link

supabase bot commented Feb 22, 2026

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Copy link
Collaborator Author

sid597 commented Feb 22, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@sid597
Copy link
Collaborator Author

sid597 commented Feb 22, 2026

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 22, 2026

✅ Actions performed

Full review triggered.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

import sendErrorEmail from "~/utils/sendErrorEmail";
import { getSetting } from "~/utils/extensionSettings";
import { DISALLOW_DIAGNOSTICS } from "~/data/userSettings";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Circular dependency between internalError and getPersonalSetting degrades error-handler robustness

internalError is the application's error handler of last resort — it must never throw. This PR replaces the simple getSetting(DISALLOW_DIAGNOSTICS, false) call with getPersonalSetting<boolean>(["Disable product diagnostics"]), which introduces a circular module dependency and a significantly more complex runtime path.

Root cause and impact

Circular dependency: internalError.ts now imports getPersonalSetting from accessors.ts, while accessors.ts imports internalError from internalError.ts. The old code imported getSetting from extensionSettings.ts which did NOT import internalError, so no cycle existed.

More complex call chain: The new getPersonalSetting call goes through isDualReadEnabled()getFeatureFlag()getFeatureFlags()getBlockPropBasedSettings()getBlockUidByTextOnPage() + getBlockProps() + FeatureFlagsSchema.parse() — all BEFORE reaching the legacy getSetting fallback. Each additional step is a potential throw site.

If any of these calls fail (e.g., Roam data queries are in a bad state — which is more likely when errors are already occurring), internalError itself throws, meaning:

  1. The original error being reported is silently lost
  2. Error telemetry (PostHog + email) never fires
  3. The caller crashes with an unrelated settings-read error

The old getSetting was a single extensionAPI.settings.get() call with a hardcoded false default — far fewer points of failure.

Prompt for agents
In apps/roam/src/utils/internalError.ts, the call to getPersonalSetting on line 25 should be wrapped in a try-catch to ensure internalError never throws due to a settings-read failure. Replace the getPersonalSetting call at line 25 with a safe wrapper, for example:

let diagnosticsDisabled = false;
try {
  diagnosticsDisabled = getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
} catch {
  // If we can't read settings, default to allowing diagnostics so errors get reported
  diagnosticsDisabled = false;
}

Alternatively, consider keeping the simpler getSetting import from extensionSettings.ts for internalError specifically, to avoid the circular module dependency between internalError.ts and accessors.ts (since accessors.ts already imports internalError).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 22, 2026

📝 Walkthrough

Walkthrough

This PR migrates feature flag and preference retrievals from global extension settings (via getSetting) to personal per-user settings (via getPersonalSetting). The changes affect 12 files across components, utilities, and settings UI, establishing personal settings as the single source of truth for user preferences.

Changes

Cohort / File(s) Summary
Canvas & UI Components
apps/roam/src/components/DiscourseFloatingMenu.tsx, apps/roam/src/components/canvas/DiscourseNodeUtil.tsx, apps/roam/src/components/canvas/Tldraw.tsx, apps/roam/src/components/canvas/uiOverrides.tsx
Replaced runtime extension API setting checks with getPersonalSetting calls for feature toggles: feedback button visibility, overlay/canvas relations preferences, and discourse tool keyboard shortcut.
Settings UI Configuration
apps/roam/src/components/settings/HomePersonalSettings.tsx, apps/roam/src/components/settings/QuerySettings.tsx
Removed getSetting usage and initialValue props from PersonalFlagPanel components; initialization now relies on component defaults rather than explicit per-item defaults read from extension settings.
Settings Infrastructure
apps/roam/src/components/settings/utils/accessors.ts
Removed legacy default overrides map (PERSONAL_OLD_DEFAULT_OVERRIDES) and simplified getLegacyPersonalSetting to always read defaults from DEFAULT_PERSONAL_SETTINGS.
Core Utilities & Initialization
apps/roam/src/index.ts, apps/roam/src/utils/initializeObserversAndListeners.ts, apps/roam/src/utils/createDiscourseNode.ts
Replaced getSetting calls with getPersonalSetting for diagnostics, styling, overlay, and sidebar preferences; one location adds return statement to propagate creation promise in page action listener.
Error Handling & Analytics
apps/roam/src/utils/internalError.ts, apps/roam/src/utils/posthog.ts
Replaced getSetting with getPersonalSetting for "Disable product diagnostics" flag in error reporting and PostHog initialization flows.
Command Palette
apps/roam/src/utils/registerCommandPaletteCommands.ts
Updated toggle commands to read settings via getPersonalSetting, sync both extension and personal settings on update, and include error messages in toast notifications.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: migrating personal boolean flag consumers from getSetting to getPersonalSetting across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/roam/src/utils/registerCommandPaletteCommands.ts (1)

146-168: ⚠️ Potential issue | 🟠 Major

getPersonalSetting is called outside the try/catch — a throw causes a silent unhandled rejection.

In both toggleDiscourseContextOverlay (lines 147–149) and toggleQueryMetadata (lines 171–174), getPersonalSetting is called before the try block. The callers invoke these via () => void fn(), so any rejected promise is swallowed silently — users get no feedback and the toggle does nothing.

Move the getPersonalSetting calls inside the try/catch (or add a guard before it) so failures surface as toast messages consistent with the rest of the error-handling here.

🛡️ Proposed fix for `toggleDiscourseContextOverlay`
  const toggleDiscourseContextOverlay = async () => {
-   const currentValue = getPersonalSetting<boolean>([
-     "Discourse context overlay",
-   ]);
-   const newValue = !currentValue;
    try {
+     const currentValue = getPersonalSetting<boolean>([
+       "Discourse context overlay",
+     ]);
+     const newValue = !currentValue;
      await extensionAPI.settings.set("discourse-context-overlay", newValue);
      setPersonalSetting(["Discourse context overlay"], newValue);
+     const overlayHandler = getOverlayHandler(onloadArgs);
+     onPageRefObserverChange(overlayHandler)(newValue);
+     renderToast({
+       id: "discourse-context-overlay-toggle",
+       content: `Discourse context overlay ${newValue ? "enabled" : "disabled"}`,
+     });
    } catch (error) {
      const e = error as Error;
      renderToast({
        id: "discourse-context-overlay-toggle-error",
        content: `Failed to toggle discourse context overlay: ${e.message}`,
      });
      return;
    }
-   const overlayHandler = getOverlayHandler(onloadArgs);
-   onPageRefObserverChange(overlayHandler)(newValue);
-   renderToast({
-     id: "discourse-context-overlay-toggle",
-     content: `Discourse context overlay ${newValue ? "enabled" : "disabled"}`,
-   });
  };

Apply the same pattern to toggleQueryMetadata.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/registerCommandPaletteCommands.ts` around lines 146 -
168, Both toggleDiscourseContextOverlay and toggleQueryMetadata call
getPersonalSetting outside their try/catch, which can throw and produce an
unhandled rejection; move the getPersonalSetting calls into the existing try
block (or add an explicit guard that catches errors) inside
toggleDiscourseContextOverlay and toggleQueryMetadata so any thrown error is
caught and the renderToast error path runs; update references to
currentValue/newValue accordingly inside the try so the subsequent settings.set,
setPersonalSetting, onPageRefObserverChange(onloadArgs) calls and
success/failure toasts behave consistently.
apps/roam/src/components/canvas/Tldraw.tsx (1)

1089-1101: ⚠️ Potential issue | 🟠 Major

getPersonalSetting inside the per-shape afterCreate side-effect can throw on every canvas interaction.

This handler fires for every shape creation. If getPersonalSetting throws (dual-read enabled, setting not yet available), tldraw's side effect system receives an unhandled exception on each new shape, potentially breaking canvas interactions repeatedly.

🛡️ Proposed fix
       editor.sideEffects.registerAfterCreateHandler("shape", (shape) => {
         const util = editor.getShapeUtil(shape);
         if (util instanceof BaseDiscourseNodeUtil) {
-          const autoCanvasRelations = getPersonalSetting<boolean>([
-            "Auto canvas relations",
-          ]);
+          let autoCanvasRelations = false;
+          try {
+            autoCanvasRelations = getPersonalSetting<boolean>(["Auto canvas relations"]) ?? false;
+          } catch {
+            // setting unavailable; skip auto-relations
+          }
           if (autoCanvasRelations) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/components/canvas/Tldraw.tsx` around lines 1089 - 1101, The
per-shape afterCreate handler registered via
editor.sideEffects.registerAfterCreateHandler ("shape") calls getPersonalSetting
synchronously and can throw, causing unhandled exceptions on every shape
creation; fix it by making the handler defensive: wrap the getPersonalSetting
call in a try/catch (or check for availability) and bail silently on error, and
only call util.createExistingRelations when the setting is safely read and true;
reference the removeAfterCreateHandler registration, the BaseDiscourseNodeUtil
check, getPersonalSetting call, and util.createExistingRelations to locate and
update the code.
apps/roam/src/utils/initializeObserversAndListeners.ts (1)

207-235: ⚠️ Potential issue | 🟠 Major

getPersonalSetting calls in initObservers run before initSchema() — same ordering risk as index.ts

initObservers is invoked at index.ts line 101, before initSchema() at line 158. All three getPersonalSetting calls here (lines 207, 230, 232) will throw for any unset setting when isDualReadEnabled() is true, causing initObservers to reject and the extension to fail to load.

🐛 Add `?? false` fallbacks
-  if (getPersonalSetting<boolean>(["Suggestive mode overlay"])) {
+  if (getPersonalSetting<boolean>(["Suggestive mode overlay"]) ?? false) {
     addPageRefObserver(getSuggestiveOverlayHandler(onloadArgs));
   }

-  if (getPersonalSetting<boolean>(["Page preview"]))
+  if (getPersonalSetting<boolean>(["Page preview"]) ?? false)
     addPageRefObserver(previewPageRefHandler);
-  if (getPersonalSetting<boolean>(["Discourse context overlay"])) {
+  if (getPersonalSetting<boolean>(["Discourse context overlay"]) ?? false) {
     const overlayHandler = getOverlayHandler(onloadArgs);
     onPageRefObserverChange(overlayHandler)(true);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/initializeObserversAndListeners.ts` around lines 207 -
235, The three calls to getPersonalSetting inside initObservers (the ones gating
addPageRefObserver(getSuggestiveOverlayHandler(onloadArgs)), the Page preview
branch that uses previewPageRefHandler, and the Discourse context overlay branch
that calls getOverlayHandler/onPageRefObserverChange) can throw when schema
isn't initialized; update each getPersonalSetting<boolean>(...) invocation to
safely coalesce to false (e.g., getPersonalSetting<boolean>([...]) ?? false) so
they never throw when isDualReadEnabled() or initSchema() haven't run yet.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/roam/src/components/canvas/DiscourseNodeUtil.tsx`:
- Around line 515-519: The call to getPersonalSetting that assigns
autoCanvasRelations is currently outside the try/catch so its exceptions can
bypass the renderToast error handler; move the
getPersonalSetting<boolean>(["Auto canvas relations"]) call inside the try block
that follows (the same guarded section that uses renderToast and the onSuccess
callback), so any thrown error is caught and handled by the existing catch which
calls renderToast; keep the same variable name autoCanvasRelations and ensure
the rest of the code in that try block uses the moved variable.
- Around line 433-436: The call to getPersonalSetting(["Overlay in canvas"])
inside the useMemo that defines isOverlayEnabled can throw during render
(especially when isDualReadEnabled() is true and the setting is unset) and the
empty dependency array prevents reacting to setting changes; change this by
moving the getPersonalSetting call out of a render-time exception-prone context
or wrap it in a safe try/catch that returns a sensible default (e.g., false) and
log/handle the error, and also replace the empty deps with a settings-dependent
value or subscribe to the settings/context so isOverlayEnabled updates when the
personal setting changes; look for the isOverlayEnabled constant, the useMemo
call, getPersonalSetting, and isDualReadEnabled to implement the defensive
fallback and dependency fix.

In `@apps/roam/src/components/canvas/uiOverrides.tsx`:
- Around line 273-281: Wrap the getPersonalSetting call in a try/catch and
validate its shape before accessing .key: catch any exceptions from
getPersonalSetting("Discourse tool shortcut") and fall back to a safe default
object (e.g., { key: "", modifiers: 0 }), then ensure the returned value
actually matches the expected shape (IKeyCombo-like) before using .key (e.g.,
check typeof combo === "object" && typeof combo.key === "string"); finally
compute discourseToolShortcut from that validated value so rendering won't throw
in the tools override (refer to discourseToolCombo, getPersonalSetting,
IKeyCombo, and discourseToolShortcut).

In `@apps/roam/src/components/DiscourseFloatingMenu.tsx`:
- Around line 122-124: installDiscourseFloatingMenu currently calls
getPersonalSetting([... "Hide feedback button"]) during extension install which
can throw (when dual-read is enabled and the setting path is undefined), causing
install to abort before ReactDOM.render; wrap the getPersonalSetting call inside
a try/catch (or otherwise guard it) in
installDiscourseFloatingMenu/DiscourseFloatingMenu initialization, treat
failures as the safe default (e.g., false), and only call
floatingMenuAnchor.classList.add("hidden") when the guarded result is true so
the floating menu still renders even if the accessor throws.

In `@apps/roam/src/index.ts`:
- Around line 47-49: The calls to getPersonalSetting (e.g., for
disallowDiagnostics) occur inside runExtension before initSchema(), so when
isDualReadEnabled() is true and the setting is undefined it can throw and abort
startup; move these getPersonalSetting calls (and any observer initialization
that reads settings such as initObservers/initializeObserversAndListeners) to
after initSchema() is awaited, or alternatively add a safe fallback at each call
site by checking isDualReadEnabled() and treating undefined as the desired
default (e.g., false) before using the value; update usages including the
disallowDiagnostics variable and any other pre-schema reads to either be
relocated after initSchema() or wrapped with the fallback check so the extension
won't throw on first load.

In `@apps/roam/src/utils/createDiscourseNode.ts`:
- Around line 35-37: handleOpenInSidebar currently calls getPersonalSetting
synchronously which, if it throws, can cause createDiscourseNode to reject after
the Roam node was already created; wrap the call to getPersonalSetting (and the
subsequent openBlockInSidebar invocation) in a try/catch inside
handleOpenInSidebar, default to treating the setting as "allow" when an error
occurs (i.e., proceed without opening the sidebar) or simply swallow the error
so node creation doesn't reject, and keep the existing void
openBlockInSidebar(uid) invocation in the try block to preserve fire-and-forget
behavior.

In `@apps/roam/src/utils/initializeObserversAndListeners.ts`:
- Around line 385-388: The current check in nodeCreationPopoverListener uses
getPersonalSetting(...) !== false which fails when getPersonalSetting throws in
the dual-read path; instead wrap the getPersonalSetting call in a try/catch to
treat thrown errors as unset, then apply nullish-coalescing to preserve opt-out
semantics: let val; try { val = getPersonalSetting<boolean>(["Text selection
popup"]); } catch { val = undefined; } const isTextSelectionPopupEnabled = val
?? true; update the logic in the nodeCreationPopoverListener closure accordingly
(referencing nodeCreationPopoverListener and getPersonalSetting).

In `@apps/roam/src/utils/internalError.ts`:
- Around line 24-28: The call to getPersonalSetting inside internalError can
throw (see isDualReadEnabled behavior) which can make internalError propagate;
wrap the getPersonalSetting<boolean>(["Disable product diagnostics"]) call in a
try/catch (or extract it above and catch errors) so that any thrown error is
swallowed/handled and treated as a safe default (e.g., false/undefined) before
proceeding with the existing conditional; modify internalError to use that safe
result so internalError itself cannot throw when invoked from
validateSettingValue, setPersonalSetting, or setGlobalSetting.

In `@apps/roam/src/utils/posthog.ts`:
- Around line 71-75: The call to getPersonalSetting inside initPostHog can throw
during early startup and currently prevents doInitPostHog from running; wrap the
getPersonalSetting<boolean>(["Disable product diagnostics"]) call in a
try/catch, log the caught error (using the module's logger or console.error),
treat the setting as not disabled on error (i.e., default to false) and ensure
doInitPostHog() is called in that fallback path so PostHog is initialized even
if settings are temporarily unavailable.

---

Outside diff comments:
In `@apps/roam/src/components/canvas/Tldraw.tsx`:
- Around line 1089-1101: The per-shape afterCreate handler registered via
editor.sideEffects.registerAfterCreateHandler ("shape") calls getPersonalSetting
synchronously and can throw, causing unhandled exceptions on every shape
creation; fix it by making the handler defensive: wrap the getPersonalSetting
call in a try/catch (or check for availability) and bail silently on error, and
only call util.createExistingRelations when the setting is safely read and true;
reference the removeAfterCreateHandler registration, the BaseDiscourseNodeUtil
check, getPersonalSetting call, and util.createExistingRelations to locate and
update the code.

In `@apps/roam/src/utils/initializeObserversAndListeners.ts`:
- Around line 207-235: The three calls to getPersonalSetting inside
initObservers (the ones gating
addPageRefObserver(getSuggestiveOverlayHandler(onloadArgs)), the Page preview
branch that uses previewPageRefHandler, and the Discourse context overlay branch
that calls getOverlayHandler/onPageRefObserverChange) can throw when schema
isn't initialized; update each getPersonalSetting<boolean>(...) invocation to
safely coalesce to false (e.g., getPersonalSetting<boolean>([...]) ?? false) so
they never throw when isDualReadEnabled() or initSchema() haven't run yet.

In `@apps/roam/src/utils/registerCommandPaletteCommands.ts`:
- Around line 146-168: Both toggleDiscourseContextOverlay and
toggleQueryMetadata call getPersonalSetting outside their try/catch, which can
throw and produce an unhandled rejection; move the getPersonalSetting calls into
the existing try block (or add an explicit guard that catches errors) inside
toggleDiscourseContextOverlay and toggleQueryMetadata so any thrown error is
caught and the renderToast error path runs; update references to
currentValue/newValue accordingly inside the try so the subsequent settings.set,
setPersonalSetting, onPageRefObserverChange(onloadArgs) calls and
success/failure toasts behave consistently.

Comment on lines 433 to 436
const isOverlayEnabled = useMemo(
() => getSetting(DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY, false),
() => getPersonalSetting<boolean>(["Overlay in canvas"]),
[],
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getPersonalSetting inside useMemo can throw during render

When isDualReadEnabled() is true and the "Overlay in canvas" setting is unset, getPersonalSetting throws, propagating as an unhandled render error (crashes the shape component unless an error boundary catches it).

Additionally, the empty dependency array means live setting changes during a session are silently ignored until the canvas remounts. If that's intentional, a comment is warranted; otherwise the call should move outside the component or listen to a settings context.

🐛 Proposed defensive fallback
-    const isOverlayEnabled = useMemo(
-      () => getPersonalSetting<boolean>(["Overlay in canvas"]),
-      [],
-    );
+    const isOverlayEnabled = useMemo(
+      () => getPersonalSetting<boolean>(["Overlay in canvas"]) ?? false,
+      [],
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isOverlayEnabled = useMemo(
() => getSetting(DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY, false),
() => getPersonalSetting<boolean>(["Overlay in canvas"]),
[],
);
const isOverlayEnabled = useMemo(
() => getPersonalSetting<boolean>(["Overlay in canvas"]) ?? false,
[],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/components/canvas/DiscourseNodeUtil.tsx` around lines 433 -
436, The call to getPersonalSetting(["Overlay in canvas"]) inside the useMemo
that defines isOverlayEnabled can throw during render (especially when
isDualReadEnabled() is true and the setting is unset) and the empty dependency
array prevents reacting to setting changes; change this by moving the
getPersonalSetting call out of a render-time exception-prone context or wrap it
in a safe try/catch that returns a sensible default (e.g., false) and log/handle
the error, and also replace the empty deps with a settings-dependent value or
subscribe to the settings/context so isOverlayEnabled updates when the personal
setting changes; look for the isOverlayEnabled constant, the useMemo call,
getPersonalSetting, and isDualReadEnabled to implement the defensive fallback
and dependency fix.

Comment on lines +515 to 519
const autoCanvasRelations = getPersonalSetting<boolean>([
"Auto canvas relations",
]);
if (autoCanvasRelations) {
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getPersonalSetting for autoCanvasRelations is outside the try/catch — unhandled throw

The getPersonalSetting call at line 515 sits before the try block at line 519. If it throws (dual-read path, unset setting), the error bypasses the renderToast error handler and propagates uncaught through the onSuccess callback.

🐛 Move the read inside the guarded block
-            const autoCanvasRelations = getPersonalSetting<boolean>([
-              "Auto canvas relations",
-            ]);
-            if (autoCanvasRelations) {
-              try {
+            try {
+              const autoCanvasRelations = getPersonalSetting<boolean>([
+                "Auto canvas relations",
+              ]);
+              if (autoCanvasRelations) {
                 const relationIds = getRelationIds();
                 this.deleteRelationsInCanvas({ shape, relationIds });
                 await this.createExistingRelations({
                   shape,
                   relationIds,
                   finalUid: uid,
                 });
-              } catch (error) {
-                renderToast({
-                  id: `discourse-node-error-${Date.now()}`,
-                  intent: "danger",
-                  content: (
-                    <span>Error creating relations: {String(error)}</span>
-                  ),
-                });
               }
+            } catch (error) {
+              renderToast({
+                id: `discourse-node-error-${Date.now()}`,
+                intent: "danger",
+                content: (
+                  <span>Error creating relations: {String(error)}</span>
+                ),
+              });
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const autoCanvasRelations = getPersonalSetting<boolean>([
"Auto canvas relations",
]);
if (autoCanvasRelations) {
try {
try {
const autoCanvasRelations = getPersonalSetting<boolean>([
"Auto canvas relations",
]);
if (autoCanvasRelations) {
const relationIds = getRelationIds();
this.deleteRelationsInCanvas({ shape, relationIds });
await this.createExistingRelations({
shape,
relationIds,
finalUid: uid,
});
}
} catch (error) {
renderToast({
id: `discourse-node-error-${Date.now()}`,
intent: "danger",
content: (
<span>Error creating relations: {String(error)}</span>
),
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/components/canvas/DiscourseNodeUtil.tsx` around lines 515 -
519, The call to getPersonalSetting that assigns autoCanvasRelations is
currently outside the try/catch so its exceptions can bypass the renderToast
error handler; move the getPersonalSetting<boolean>(["Auto canvas relations"])
call inside the try block that follows (the same guarded section that uses
renderToast and the onSuccess callback), so any thrown error is caught and
handled by the existing catch which calls renderToast; keep the same variable
name autoCanvasRelations and ensure the rest of the code in that try block uses
the moved variable.

Comment on lines +273 to 281
const discourseToolCombo = getPersonalSetting<IKeyCombo>([
"Discourse tool shortcut",
]) || {
key: "",
modifiers: 0,
}) as IKeyCombo;
};

// For discourse tool, just use the key directly since we don't allow modifiers
const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getPersonalSetting can throw here — the || fallback only handles falsy returns, not exceptions; a throw during rendering breaks the canvas.

The || on line 275 handles undefined but not a thrown error. If getPersonalSetting throws inside the tools override (called during tldraw rendering), the entire canvas UI fails to render.

Additionally, getPersonalSetting<IKeyCombo> is a TypeScript-only type assertion — the stored JSON value may not actually match IKeyCombo at runtime (e.g., if legacy settings stored just a string). The .key access on line 281 would then silently return undefined.

🛡️ Proposed fix
-   const discourseToolCombo = getPersonalSetting<IKeyCombo>([
-     "Discourse tool shortcut",
-   ]) || {
-     key: "",
-     modifiers: 0,
-   };
+   let discourseToolCombo: IKeyCombo = { key: "", modifiers: 0 };
+   try {
+     const stored = getPersonalSetting<IKeyCombo>(["Discourse tool shortcut"]);
+     if (stored && typeof stored === "object" && "key" in stored) {
+       discourseToolCombo = stored;
+     }
+   } catch {
+     // setting unavailable; use default
+   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const discourseToolCombo = getPersonalSetting<IKeyCombo>([
"Discourse tool shortcut",
]) || {
key: "",
modifiers: 0,
}) as IKeyCombo;
};
// For discourse tool, just use the key directly since we don't allow modifiers
const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";
let discourseToolCombo: IKeyCombo = { key: "", modifiers: 0 };
try {
const stored = getPersonalSetting<IKeyCombo>(["Discourse tool shortcut"]);
if (stored && typeof stored === "object" && "key" in stored) {
discourseToolCombo = stored;
}
} catch {
// setting unavailable; use default
}
// For discourse tool, just use the key directly since we don't allow modifiers
const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/components/canvas/uiOverrides.tsx` around lines 273 - 281, Wrap
the getPersonalSetting call in a try/catch and validate its shape before
accessing .key: catch any exceptions from getPersonalSetting("Discourse tool
shortcut") and fall back to a safe default object (e.g., { key: "", modifiers: 0
}), then ensure the returned value actually matches the expected shape
(IKeyCombo-like) before using .key (e.g., check typeof combo === "object" &&
typeof combo.key === "string"); finally compute discourseToolShortcut from that
validated value so rendering won't throw in the tools override (refer to
discourseToolCombo, getPersonalSetting, IKeyCombo, and discourseToolShortcut).

Comment on lines +122 to 124
if (getPersonalSetting<boolean>(["Hide feedback button"])) {
floatingMenuAnchor.classList.add("hidden");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unguarded getPersonalSetting at install time — a throw would prevent the floating menu from rendering.

getPersonalSetting throws when dual-read is enabled and the setting path resolves to undefined (see accessors.ts line 759). If this occurs during extension load (e.g., settings page not yet created), installDiscourseFloatingMenu aborts before ReactDOM.render is reached and no UI feedback is given.

🛡️ Proposed fix
- if (getPersonalSetting<boolean>(["Hide feedback button"])) {
+ if (getPersonalSetting<boolean>(["Hide feedback button"]) === true) {

Actually the safer fix is wrapping:

+ let hideFeedback = false;
+ try { hideFeedback = getPersonalSetting<boolean>(["Hide feedback button"]) ?? false; }
+ catch { /* default to visible */ }
+ if (hideFeedback) {
-   if (getPersonalSetting<boolean>(["Hide feedback button"])) {
    floatingMenuAnchor.classList.add("hidden");
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (getPersonalSetting<boolean>(["Hide feedback button"])) {
floatingMenuAnchor.classList.add("hidden");
}
let hideFeedback = false;
try {
hideFeedback = getPersonalSetting<boolean>(["Hide feedback button"]) ?? false;
} catch {
/* default to visible */
}
if (hideFeedback) {
floatingMenuAnchor.classList.add("hidden");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/components/DiscourseFloatingMenu.tsx` around lines 122 - 124,
installDiscourseFloatingMenu currently calls getPersonalSetting([... "Hide
feedback button"]) during extension install which can throw (when dual-read is
enabled and the setting path is undefined), causing install to abort before
ReactDOM.render; wrap the getPersonalSetting call inside a try/catch (or
otherwise guard it) in installDiscourseFloatingMenu/DiscourseFloatingMenu
initialization, treat failures as the safe default (e.g., false), and only call
floatingMenuAnchor.classList.add("hidden") when the guarded result is true so
the floating menu still renders even if the accessor throws.

Comment on lines +47 to +49
const disallowDiagnostics = getPersonalSetting<boolean>([
"Disable product diagnostics",
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Step 1: Find isDualReadEnabled definition and understand its behavior
echo "=== isDualReadEnabled definition ==="
rg -n "isDualReadEnabled" --type ts -A 5

echo ""
echo "=== initSchema definition and its behavior ==="
# Find initSchema to see if it populates defaults
rg -n "initSchema" --type ts -A 10 | head -50

echo ""
echo "=== Execution order in index.ts ==="
# Confirm execution order in index.ts
grep -n "initObservers\|initSchema\|getPersonalSetting\|runExtension" apps/roam/src/index.ts | head -30

echo ""
echo "=== getPersonalSetting implementation ==="
rg -n "getPersonalSetting" --type ts -B 2 -A 15 | head -80

Repository: DiscourseGraphs/discourse-graph

Length of output: 9768


🏁 Script executed:

#!/bin/bash
# Get full getPersonalSetting implementation
echo "=== Full getPersonalSetting implementation ==="
rg -n "export const getPersonalSetting" --type ts -A 15

echo ""
echo "=== readPathValue implementation ==="
rg -n "readPathValue" --type ts -B 2 -A 10 | head -40

echo ""
echo "=== getGlobalSettings implementation ==="
rg -n "getGlobalSettings" --type ts -A 8 | head -30

echo ""
echo "=== What initSchema actually does ==="
cat -n apps/roam/src/components/settings/utils/init.ts | head -50

Repository: DiscourseGraphs/discourse-graph

Length of output: 12078


getPersonalSetting called before settings are initialized — will throw on first load when dual-read is enabled

Both getPersonalSetting calls (lines 47–49 and 92–94) execute inside runExtension before initSchema() at line 158. When isDualReadEnabled() returns true, getPersonalSetting throws an Error if the setting value is undefined (e.g., a new user with no saved preferences). This aborts the entire extension load.

Two options:

  1. Move the calls after initSchema() (preferred — ensures settings are bootstrapped).
  2. Add a fallback at each call site.
Option 2 — safe fallback for both reads
-  const disallowDiagnostics = getPersonalSetting<boolean>([
-    "Disable product diagnostics",
-  ]);
+  const disallowDiagnostics =
+    getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
-  const isStreamlineStylingEnabled = getPersonalSetting<boolean>([
-    "Streamline styling",
-  ]);
+  const isStreamlineStylingEnabled =
+    getPersonalSetting<boolean>(["Streamline styling"]) ?? false;

Note: initObservers (line 101) is called between these two groups and has the same problem — see the comment on initializeObserversAndListeners.ts.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const disallowDiagnostics = getPersonalSetting<boolean>([
"Disable product diagnostics",
]);
const disallowDiagnostics =
getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/index.ts` around lines 47 - 49, The calls to getPersonalSetting
(e.g., for disallowDiagnostics) occur inside runExtension before initSchema(),
so when isDualReadEnabled() is true and the setting is undefined it can throw
and abort startup; move these getPersonalSetting calls (and any observer
initialization that reads settings such as
initObservers/initializeObserversAndListeners) to after initSchema() is awaited,
or alternatively add a safe fallback at each call site by checking
isDualReadEnabled() and treating undefined as the desired default (e.g., false)
before using the value; update usages including the disallowDiagnostics variable
and any other pre-schema reads to either be relocated after initSchema() or
wrapped with the fallback check so the extension won't throw on first load.

Comment on lines 35 to +37
const handleOpenInSidebar = (uid: string) => {
if (extensionAPI?.settings.get("disable-sidebar-open")) return;
openBlockInSidebar(uid);
if (getPersonalSetting<boolean>(["Disable sidebar open"])) return;
void openBlockInSidebar(uid);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

A throw from getPersonalSetting inside handleOpenInSidebar would abort node creation mid-flight.

The node may already have been created in Roam (e.g. at line 117 after createBlock) before handleOpenInSidebar is called. If getPersonalSetting throws here, the async createDiscourseNode call rejects, leaving the caller thinking creation failed even though the node exists.

🛡️ Proposed fix
  const handleOpenInSidebar = (uid: string) => {
-   if (getPersonalSetting<boolean>(["Disable sidebar open"])) return;
+   try {
+     if (getPersonalSetting<boolean>(["Disable sidebar open"])) return;
+   } catch {
+     // Setting unavailable; proceed to open sidebar
+   }
    void openBlockInSidebar(uid);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/createDiscourseNode.ts` around lines 35 - 37,
handleOpenInSidebar currently calls getPersonalSetting synchronously which, if
it throws, can cause createDiscourseNode to reject after the Roam node was
already created; wrap the call to getPersonalSetting (and the subsequent
openBlockInSidebar invocation) in a try/catch inside handleOpenInSidebar,
default to treating the setting as "allow" when an error occurs (i.e., proceed
without opening the sidebar) or simply swallow the error so node creation
doesn't reject, and keep the existing void openBlockInSidebar(uid) invocation in
the try block to preserve fire-and-forget behavior.

Comment on lines 385 to 388
const nodeCreationPopoverListener = debounce(() => {
const isTextSelectionPopupEnabled =
onloadArgs.extensionAPI.settings.get("text-selection-popup") !== false;
getPersonalSetting<boolean>(["Text selection popup"]) !== false;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

!== false opt-out semantics break in dual-read path

"Text selection popup" is intentionally opt-out (enabled by default). This relies on getPersonalSetting returning undefined for an unset setting (undefined !== false === true). However, when isDualReadEnabled() is true, getPersonalSetting throws instead of returning undefined, so the default-enabled behaviour is lost entirely.

The correct fallback here is ?? true (not ?? false), to preserve opt-out semantics:

🐛 Preserve opt-out default
     const isTextSelectionPopupEnabled =
-      getPersonalSetting<boolean>(["Text selection popup"]) !== false;
+      (getPersonalSetting<boolean>(["Text selection popup"]) ?? true) !== false;

Or more readably:

     const isTextSelectionPopupEnabled =
-      getPersonalSetting<boolean>(["Text selection popup"]) !== false;
+      getPersonalSetting<boolean>(["Text selection popup"]) ?? true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const nodeCreationPopoverListener = debounce(() => {
const isTextSelectionPopupEnabled =
onloadArgs.extensionAPI.settings.get("text-selection-popup") !== false;
getPersonalSetting<boolean>(["Text selection popup"]) !== false;
const nodeCreationPopoverListener = debounce(() => {
const isTextSelectionPopupEnabled =
(getPersonalSetting<boolean>(["Text selection popup"]) ?? true) !== false;
Suggested change
const nodeCreationPopoverListener = debounce(() => {
const isTextSelectionPopupEnabled =
onloadArgs.extensionAPI.settings.get("text-selection-popup") !== false;
getPersonalSetting<boolean>(["Text selection popup"]) !== false;
const nodeCreationPopoverListener = debounce(() => {
const isTextSelectionPopupEnabled =
getPersonalSetting<boolean>(["Text selection popup"]) ?? true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/initializeObserversAndListeners.ts` around lines 385 -
388, The current check in nodeCreationPopoverListener uses
getPersonalSetting(...) !== false which fails when getPersonalSetting throws in
the dual-read path; instead wrap the getPersonalSetting call in a try/catch to
treat thrown errors as unset, then apply nullish-coalescing to preserve opt-out
semantics: let val; try { val = getPersonalSetting<boolean>(["Text selection
popup"]); } catch { val = undefined; } const isTextSelectionPopupEnabled = val
?? true; update the logic in the nodeCreationPopoverListener closure accordingly
(referencing nodeCreationPopoverListener and getPersonalSetting).

Comment on lines 24 to 28
if (
getSetting(DISALLOW_DIAGNOSTICS, false) ||
getPersonalSetting<boolean>(["Disable product diagnostics"]) ||
(process.env.NODE_ENV === "development" && forceSendInDev !== true)
) {
console.error(error, context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

internalError can itself throw — wrap getPersonalSetting in a try/catch here.

getPersonalSetting throws a new Error(...) whenever isDualReadEnabled() is true and the resolved value is undefined (see accessors.ts lines 759-761). Even if the Zod schema provides defaults making this unlikely in production, the call is inside the error-handling utility that the rest of the codebase relies on to be unconditionally safe:

  • internalError is called by validateSettingValue, which is called by setPersonalSetting/setGlobalSetting. Any error during a settings write now triggers a path that calls back into the same settings infrastructure.
  • If getPersonalSetting throws before Roam block-props are ready (e.g., very early in extension init), internalError propagates instead of swallowing.
🐛 Proposed fix — guard against throw
  if (
-   getPersonalSetting<boolean>(["Disable product diagnostics"]) ||
+   (() => {
+     try { return getPersonalSetting<boolean>(["Disable product diagnostics"]); }
+     catch { return false; }
+   })() ||
    (process.env.NODE_ENV === "development" && forceSendInDev !== true)
  ) {

Or extract it above the if:

+ let diagnosticsDisabled = false;
+ try {
+   diagnosticsDisabled = getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
+ } catch {
+   // settings not ready; fall through to full error reporting
+ }
  if (
-   getPersonalSetting<boolean>(["Disable product diagnostics"]) ||
+   diagnosticsDisabled ||
    (process.env.NODE_ENV === "development" && forceSendInDev !== true)
  ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
getSetting(DISALLOW_DIAGNOSTICS, false) ||
getPersonalSetting<boolean>(["Disable product diagnostics"]) ||
(process.env.NODE_ENV === "development" && forceSendInDev !== true)
) {
console.error(error, context);
let diagnosticsDisabled = false;
try {
diagnosticsDisabled = getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
} catch {
// settings not ready; fall through to full error reporting
}
if (
diagnosticsDisabled ||
(process.env.NODE_ENV === "development" && forceSendInDev !== true)
) {
console.error(error, context);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/internalError.ts` around lines 24 - 28, The call to
getPersonalSetting inside internalError can throw (see isDualReadEnabled
behavior) which can make internalError propagate; wrap the
getPersonalSetting<boolean>(["Disable product diagnostics"]) call in a try/catch
(or extract it above and catch errors) so that any thrown error is
swallowed/handled and treated as a safe default (e.g., false/undefined) before
proceeding with the existing conditional; modify internalError to use that safe
result so internalError itself cannot throw when invoked from
validateSettingValue, setPersonalSetting, or setGlobalSetting.

Comment on lines 71 to 75
export const initPostHog = (): void => {
const disabled = getSetting(DISALLOW_DIAGNOSTICS, false);
const disabled = getPersonalSetting<boolean>(["Disable product diagnostics"]);
if (!disabled) {
doInitPostHog();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getPersonalSetting call in initPostHog can throw during early initialization — no error guard.

initPostHog is called at extension startup before settings block-props may be fully available. If getPersonalSetting throws (dual-read enabled, settings page not yet created), doInitPostHog is never called and PostHog remains uninitialized with no log or fallback.

🛡️ Proposed fix
 export const initPostHog = (): void => {
-  const disabled = getPersonalSetting<boolean>(["Disable product diagnostics"]);
-  if (!disabled) {
+  let disabled = false;
+  try {
+    disabled = getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
+  } catch {
+    // settings not ready; default to enabled (non-invasive)
+  }
+  if (!disabled) {
     doInitPostHog();
   }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const initPostHog = (): void => {
const disabled = getSetting(DISALLOW_DIAGNOSTICS, false);
const disabled = getPersonalSetting<boolean>(["Disable product diagnostics"]);
if (!disabled) {
doInitPostHog();
}
export const initPostHog = (): void => {
let disabled = false;
try {
disabled = getPersonalSetting<boolean>(["Disable product diagnostics"]) ?? false;
} catch {
// settings not ready; default to enabled (non-invasive)
}
if (!disabled) {
doInitPostHog();
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/roam/src/utils/posthog.ts` around lines 71 - 75, The call to
getPersonalSetting inside initPostHog can throw during early startup and
currently prevents doInitPostHog from running; wrap the
getPersonalSetting<boolean>(["Disable product diagnostics"]) call in a
try/catch, log the caught error (using the module's logger or console.error),
treat the setting as not disabled on error (i.e., default to false) and ensure
doInitPostHog() is called in that fallback path so PostHog is initialized even
if settings are temporarily unavailable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant