Skip to content

Commit b986299

Browse files
authored
ENG-1470: Fix dual-read gaps found during flag-ON validation (#896)
* ENG-1470: Fix dual-read gaps found during flag-ON validation Bootstrap legacy config blocks in initSchema so the plugin works on fresh graphs without createConfigObserver/configPageTabs: - trigger, grammar/relations, export, Suggestive Mode, Left Sidebar - Reuses existing ensureBlocksExist/buildBlockMap helpers + DEFAULT_RELATION_VALUES Fix duplicate block accumulation bugs: - Page Groups: getSubTree auto-create race (ensureLegacyConfigBlocks pre-creates) - Folded: lookup-based delete via getBasicTreeByParentUid instead of stale uid - scratch/enabled: switched getSubTree({ parentUid }) to tree-based reads - Folded in convertToComplexSection: removed erroneous block creation Fix dual-read comparison: - Replace JSON.stringify with deepEqual (handles key order, undefined/empty/false) - Deterministic async ordering: await legacy write → refreshConfigTree → blockProp write (BlockPropSettingPanels, LeftSidebarView toggleFoldedState, AdminPanel suggestive mode) - Use getPersonalSettings() (raw read) in toggleFoldedState to avoid mid-write comparison Fix storedRelations import path (renderNodeConfigPage → data/constants) * Fix dual-read mismatches and ZodError on discourse node parse * Fix dual-read mismatches: alias timing, key-image re-render, deepEqual null * Fix prettier formatting * ENG-1574: Add dual-read console logs to setting setters (#914) * ENG-1574: Add dual-read console logs to setting setters Log legacy and block prop values with match/mismatch status when a setting is changed. Fix broken import in storedRelations. * ENG-1574: Add init-time dual-read log and window.dgDualReadLog() Log all legacy vs block prop settings on init. Remove setter logging. Expose dgDualReadLog() on window for on-demand use. * ENG-1574: Fix eslint naming-convention warnings in init.ts * ENG-1574: Use deepEqual instead of JSON.stringify for comparison JSON.stringify is key-order dependent, causing false mismatches when legacy and block props return keys in different order. * ENG-1574: Remove dead code, use deepEqual for comparison * ENG-1574: Fix review feedback — try-catch, flag exclusion, type guard * Eng 1616 add getconfigtree equivalent for block pros on init (#944) * ENG-1616: Bulk-read settings + thread snapshot (with timing logs) Cut plugin load from ~20925ms to ~1327ms (94%) on a real graph by collapsing per-call settings accessors into a single bulk read at init and threading that snapshot through the init chain + observer callbacks. Key changes: - accessors.ts: bulkReadSettings() runs ONE pull query against the settings page's direct children and returns { featureFlags, globalSettings, personalSettings } parsed via Zod. readPathValue exported. - getDiscourseNodes / getDiscourseRelations / getAllRelations: optional snapshot param threaded through, no breaking changes to existing callers. - initializeDiscourseNodes + refreshConfigTree (+ registerDiscourseDatalog- Translators, getDiscourseRelationLabels): accept and forward snapshot. - index.ts: bulkReadSettings() at the top of init; snapshot threaded into initializeDiscourseNodes, refreshConfigTree, initObservers, installDiscourseFloatingMenu, setInitialQueryPages, and the 3 sync sites inside index.ts itself. - initializeObserversAndListeners.ts: snapshot threaded into the sync-init body; pageTitleObserver + leftSidebarObserver callbacks call bulkReadSettings() per fire (fresh, not stale); nodeTagPopupButtonObserver uses per-sync-batch memoization via queueMicrotask; hashChangeListener and nodeCreationPopoverListener use bulkReadSettings() per fire. - findDiscourseNode: snapshot param added; getDiscourseNodes() default-arg moved inside the cache-miss branch so cache hits don't waste the call. - isQueryPage / isCanvasPage / QueryPagesPanel.getQueryPages: optional snapshot param. - LeftSidebarView.buildConfig / useConfig / mountLeftSidebar: optional initialSnapshot threaded for the first render; emitter-driven updates keep using live reads for post-mount reactivity. - DiscourseFloatingMenu.installDiscourseFloatingMenu: optional snapshot. - posthog.initPostHog: removed redundant internal getPersonalSetting check (caller already guards from the snapshot). - migrateLegacyToBlockProps.hasGraphMigrationMarker: accepts the existing blockMap and does an O(1) lookup instead of a getBlockUidByTextOnPage scan. Includes per-phase timing console.logs across index.ts, refreshConfigTree, init.ts, initSettingsPageBlocks, and initObservers. Committed as a checkpoint so we can reference measurements later; will be removed in the next commit. * ENG-1616: Remove plugin-load timing logs Removes the per-phase console.log instrumentation added in the previous commit. All the [DG Plugin] / [DG Nav] logs and their `mark()` / `markPhase()` helpers are gone. Code behavior unchanged. Dropped in this commit: - index.ts: mark() closure, load start/done logs, and all phase marks. - initializeObserversAndListeners.ts: markPhase() closure, per-observer marks, pageTitleObserver fire log, hashChangeListener [DG Nav] logs. - LeftSidebarView.tsx: openTarget [DG Nav] click/resolve logs. - refreshConfigTree.ts: mark() closure and all phase marks. - init.ts: mark() closures in initSchema and initSettingsPageBlocks. - accessors.ts: bulkReadSettings internal timing log. - index.ts: unused getPluginElapsedTime import. Previous commit (343dc11) kept as a checkpoint for future drill-downs. * ENG-1616: Address review — typed indexing, restore dgDualReadLog, optional snapshot - index.ts: move initPluginTimer() back to its original position (after early-return checks) so timing isn't started for graphs that bail out. - Replace readPathValue + `as T | undefined` casts with direct typed indexing on the Zod-derived snapshot types across: - index.ts (disallowDiagnostics, isStreamlineStylingEnabled) - initializeObserversAndListeners.ts (suggestiveModeOverlay, pagePreview, discourseContextOverlay, globalTrigger, personalTriggerCombo, customTrigger) — also drops dead `?? "\\"` and `?? "@"` fallbacks since Zod defaults already populate them. - isCanvasPage.ts (canvasPageFormat) - setQueryPages.ts + QueryPagesPanel.tsx (nested [Query][Query pages]) - setQueryPages.setInitialQueryPages: snapshot is now optional with a getPersonalSetting fallback, matching the pattern used elsewhere (getQueryPages, isCanvasPage, etc.). - init.ts: restore logDualReadComparison + window.dgDualReadLog so the on-demand console helper is available again. NOT auto-called on init — invoke window.dgDualReadLog() manually to dump the comparison. * ENG-1616: Log total plugin load time Capture performance.now() at the top of runExtension and log the elapsed milliseconds just before the unload handler is wired, so we have a single broad measurement of plugin init cost on each load. * ENG-1616: Tighten init-only leaves to required snapshot, AGENTS.md compliance Make snapshot required at six init-only leaves where caller audit showed every site already passed one: installDiscourseFloatingMenu, initializeDiscourseNodes, setInitialQueryPages, isQueryPage, isCurrentPageCanvas, isSidebarCanvas. No cascade — only at the leaves. Drop dead fallback code that was reachable only via the optional path: - setQueryPages: legacy string|Record coercion ladder (snapshot is Zod-typed string[]) - DiscourseFloatingMenu: getPersonalSetting<boolean> cast site - DiscourseFloatingMenu: unused props parameter (no caller ever overrode default) - initializeObserversAndListeners: !== false dead pattern (Zod boolean default) - initializeObserversAndListeners: as IKeyCombo cast (schema is structurally compatible) AGENTS.md compliance for >2-arg functions: - mountLeftSidebar: object-destructured params, both call sites updated - installDiscourseFloatingMenu: kept at 2 positional via dead-props removal posthog: collapse doInitPostHog wrapper into initPostHog (caller-side gating). accessors: revert speculative readPathValue export (no consumer). LeftSidebarView/DiscourseFloatingMenu: eslint-disable react/no-deprecated on ReactDOM.render rewritten lines, matching existing codebase convention. * ENG-1616: Address review — rename snapshot vars, flag-gate bulkRead, move PostHog guards - Rename settingsSnapshot/callbackSnapshot/snap/navSnapshot → settings - bulkReadSettings now checks "Use new settings store" flag and falls back to legacy reads when off, matching individual getter behavior - Move encryption/offline guards into initPostHog (diagnostics check stays at call site to avoid race with async setSetting in enablePostHog) * Fix legacy bulk settings fallback * ENG-1617: se existing 'getConfigTree equivalent functions' for specific setting groups (#946) * ENG-1616: Bulk-read settings + thread snapshot (with timing logs) Cut plugin load from ~20925ms to ~1327ms (94%) on a real graph by collapsing per-call settings accessors into a single bulk read at init and threading that snapshot through the init chain + observer callbacks. Key changes: - accessors.ts: bulkReadSettings() runs ONE pull query against the settings page's direct children and returns { featureFlags, globalSettings, personalSettings } parsed via Zod. readPathValue exported. - getDiscourseNodes / getDiscourseRelations / getAllRelations: optional snapshot param threaded through, no breaking changes to existing callers. - initializeDiscourseNodes + refreshConfigTree (+ registerDiscourseDatalog- Translators, getDiscourseRelationLabels): accept and forward snapshot. - index.ts: bulkReadSettings() at the top of init; snapshot threaded into initializeDiscourseNodes, refreshConfigTree, initObservers, installDiscourseFloatingMenu, setInitialQueryPages, and the 3 sync sites inside index.ts itself. - initializeObserversAndListeners.ts: snapshot threaded into the sync-init body; pageTitleObserver + leftSidebarObserver callbacks call bulkReadSettings() per fire (fresh, not stale); nodeTagPopupButtonObserver uses per-sync-batch memoization via queueMicrotask; hashChangeListener and nodeCreationPopoverListener use bulkReadSettings() per fire. - findDiscourseNode: snapshot param added; getDiscourseNodes() default-arg moved inside the cache-miss branch so cache hits don't waste the call. - isQueryPage / isCanvasPage / QueryPagesPanel.getQueryPages: optional snapshot param. - LeftSidebarView.buildConfig / useConfig / mountLeftSidebar: optional initialSnapshot threaded for the first render; emitter-driven updates keep using live reads for post-mount reactivity. - DiscourseFloatingMenu.installDiscourseFloatingMenu: optional snapshot. - posthog.initPostHog: removed redundant internal getPersonalSetting check (caller already guards from the snapshot). - migrateLegacyToBlockProps.hasGraphMigrationMarker: accepts the existing blockMap and does an O(1) lookup instead of a getBlockUidByTextOnPage scan. Includes per-phase timing console.logs across index.ts, refreshConfigTree, init.ts, initSettingsPageBlocks, and initObservers. Committed as a checkpoint so we can reference measurements later; will be removed in the next commit. * ENG-1616: Remove plugin-load timing logs Removes the per-phase console.log instrumentation added in the previous commit. All the [DG Plugin] / [DG Nav] logs and their `mark()` / `markPhase()` helpers are gone. Code behavior unchanged. Dropped in this commit: - index.ts: mark() closure, load start/done logs, and all phase marks. - initializeObserversAndListeners.ts: markPhase() closure, per-observer marks, pageTitleObserver fire log, hashChangeListener [DG Nav] logs. - LeftSidebarView.tsx: openTarget [DG Nav] click/resolve logs. - refreshConfigTree.ts: mark() closure and all phase marks. - init.ts: mark() closures in initSchema and initSettingsPageBlocks. - accessors.ts: bulkReadSettings internal timing log. - index.ts: unused getPluginElapsedTime import. Previous commit (343dc11) kept as a checkpoint for future drill-downs. * ENG-1616: Address review — typed indexing, restore dgDualReadLog, optional snapshot - index.ts: move initPluginTimer() back to its original position (after early-return checks) so timing isn't started for graphs that bail out. - Replace readPathValue + `as T | undefined` casts with direct typed indexing on the Zod-derived snapshot types across: - index.ts (disallowDiagnostics, isStreamlineStylingEnabled) - initializeObserversAndListeners.ts (suggestiveModeOverlay, pagePreview, discourseContextOverlay, globalTrigger, personalTriggerCombo, customTrigger) — also drops dead `?? "\\"` and `?? "@"` fallbacks since Zod defaults already populate them. - isCanvasPage.ts (canvasPageFormat) - setQueryPages.ts + QueryPagesPanel.tsx (nested [Query][Query pages]) - setQueryPages.setInitialQueryPages: snapshot is now optional with a getPersonalSetting fallback, matching the pattern used elsewhere (getQueryPages, isCanvasPage, etc.). - init.ts: restore logDualReadComparison + window.dgDualReadLog so the on-demand console helper is available again. NOT auto-called on init — invoke window.dgDualReadLog() manually to dump the comparison. * ENG-1616: Log total plugin load time Capture performance.now() at the top of runExtension and log the elapsed milliseconds just before the unload handler is wired, so we have a single broad measurement of plugin init cost on each load. * ENG-1616: Tighten init-only leaves to required snapshot, AGENTS.md compliance Make snapshot required at six init-only leaves where caller audit showed every site already passed one: installDiscourseFloatingMenu, initializeDiscourseNodes, setInitialQueryPages, isQueryPage, isCurrentPageCanvas, isSidebarCanvas. No cascade — only at the leaves. Drop dead fallback code that was reachable only via the optional path: - setQueryPages: legacy string|Record coercion ladder (snapshot is Zod-typed string[]) - DiscourseFloatingMenu: getPersonalSetting<boolean> cast site - DiscourseFloatingMenu: unused props parameter (no caller ever overrode default) - initializeObserversAndListeners: !== false dead pattern (Zod boolean default) - initializeObserversAndListeners: as IKeyCombo cast (schema is structurally compatible) AGENTS.md compliance for >2-arg functions: - mountLeftSidebar: object-destructured params, both call sites updated - installDiscourseFloatingMenu: kept at 2 positional via dead-props removal posthog: collapse doInitPostHog wrapper into initPostHog (caller-side gating). accessors: revert speculative readPathValue export (no consumer). LeftSidebarView/DiscourseFloatingMenu: eslint-disable react/no-deprecated on ReactDOM.render rewritten lines, matching existing codebase convention. * ENG-1617: Single-pull settings reads + dialog snapshot threading `getBlockPropBasedSettings` now does one Roam `pull` that returns the settings page's children with their string + uid + props in one shot, replacing the `q`-based `getBlockUidByTextOnPage` (~290ms per call) plus a second `pull` for props. `setBlockPropBasedSettings` reuses the same helper for the uid lookup so we have a single pull-and-walk pattern. `SettingsDialog` captures a full settings snapshot once at mount via `useState(() => bulkReadSettings())` and threads `featureFlags` and `personalSettings` down to `HomePersonalSettings`. Each child component (`PersonalFlagPanel`, `NodeMenuTriggerComponent`, `NodeSearchMenuTriggerSetting`, `KeyboardShortcutInput`) accepts an `initialValue` prop and seeds its local state from the snapshot instead of calling `getPersonalSetting` on mount. `PersonalFlagPanel`'s `initialValue` precedence flips so the prop wins when provided; `QuerySettings` callers without a prop still hit the existing fallback. `getDiscourseNodes`, `getDiscourseRelations`, and `getAllRelations` narrow their snapshot parameter to `Pick<SettingsSnapshot, ...>` to declare which fields each function actually reads. Adds a one-line `console.log` in `SettingsDialog` reporting the dialog open time, kept as an ongoing perf monitor. * ENG-1617: Refresh snapshot on Home tab nav + reuse readPathValue CodeRabbit catch: with `renderActiveTabPanelOnly={true}`, the Home tab's panel unmounts/remounts when the user navigates away and back. Each re-mount re-runs `useState(() => initialValue ?? false)` in `BaseFlagPanel`, re-seeding from whatever `initialValue` is currently passed. Because the dialog held the snapshot in a non-updating `useState`, that path served stale values, so toggles made earlier in the same dialog session would visually revert after a tab round-trip. Fix: hold the snapshot in a stateful slot and refresh it via `bulkReadSettings()` from the Tabs `onChange` handler when the user navigates back to Home. The setState batches with `setActiveTabId`, so the new mount sees the fresh snapshot in the same render. Also replace the inline reducer in `getBlockPropBasedSettings` with the existing `readPathValue` util — same traversal but consistent with the rest of the file and adds array-index handling for free. * ENG-1617: Per-tab snapshot threading via bulkReadSettings Replaces the dialog-level snapshot from earlier commits with a per-tab snapshot model that scales to every tab without per-tab plumbing in the dialog itself. In accessors.ts, the three plural getters (getFeatureFlags, getGlobalSettings, getPersonalSettings) now delegate to the existing bulkReadSettings, which does one Roam pull on the settings page and parses all three schemas in a single pass. The slow q-based getBlockPropBasedSettings is deleted (it was only used by the plural getters and the set path); setBlockPropBasedSettings goes back to calling getBlockUidByTextOnPage directly. Writes are infrequent enough that the q cost is acceptable on the set path. Each tab container that renders panels at mount captures one snapshot via useState(() => bulkReadSettings()) and threads the relevant slice as initialValue down to its panels: HomePersonalSettings, QuerySettings, GeneralSettings, ExportSettings. The Personal and Global panels in BlockPropSettingPanels.tsx flip their initialValue precedence to prefer the prop and fall back to the live read only when the prop is missing, so callers that don't pass initialValue (e.g. LeftSidebarGlobalSettings, which already passes its own value) continue to behave the same way. NodeMenuTriggerComponent, NodeSearchMenuTriggerSetting, and KeyboardShortcutInput accept an initialValue prop and seed local state from it instead of calling getPersonalSetting in their useState initializer. Settings.tsx wraps getDiscourseNodes() in useState so it doesn't re-run on every dialog re-render. The dialog-level snapshot, the snapshot-refresh-on-Home-tab-nav workaround, and the Pick<SettingsSnapshot, ...> type narrowings are all gone. * ENG-1617: Lift settings snapshot to SettingsDialog, thread to all tabs Move bulkReadSettings() from per-tab useState into a single call at SettingsDialog mount. Each tab receives its snapshot slice (globalSettings, personalSettings, featureFlags) as props. Remove dual-read mismatch console.warn logic from accessors. Make initialValue caller-provided in BlockPropSettingPanel wrappers. Add TabTiming wrapper for per-tab commit/paint perf measurement. * ENG-1617: Remove timing instrumentation, per-call dual-read, flag-aware bulkReadSettings - Remove TabTiming component and all console.log timing from Settings dialog - Remove per-call dual-read comparison from getGlobalSetting, getPersonalSetting, getDiscourseNodeSetting (keep logDualReadComparison for manual use) - Make bulkReadSettings flag-aware: reads from legacy when flag is OFF, block props when ON - Remove accessor fallbacks from Global/Personal wrapper panels (initialValue now always passed from snapshot) - Remove getGlobalSetting/getPersonalSetting imports from BlockPropSettingPanels * ENG-1617: Eliminate double bulkReadSettings calls in accessor functions getGlobalSetting, getPersonalSetting, getFeatureFlag, getDiscourseNodeSetting now each do a single bulkReadSettings() call instead of calling isNewSettingsStoreEnabled() (which triggered a separate bulkReadSettings) followed by another bulkReadSettings via the getter. bulkReadSettings already handles the flag check and legacy/block-props routing internally. * ENG-1617: Re-read snapshot on tab change to prevent stale initialValues Replace useState with useMemo keyed on activeTabId so bulkReadSettings() runs fresh each time the user switches tabs. Fixes stale snapshot when renderActiveTabPanelOnly unmounts/remounts panels. * ENG-1616: Address review — rename snapshot vars, flag-gate bulkRead, move PostHog guards - Rename settingsSnapshot/callbackSnapshot/snap/navSnapshot → settings - bulkReadSettings now checks "Use new settings store" flag and falls back to legacy reads when off, matching individual getter behavior - Move encryption/offline guards into initPostHog (diagnostics check stays at call site to avoid race with async setSetting in enablePostHog) * ENG-1617: Fix DiscourseNodeSelectPanel fallback to use first option instead of empty string * ENG-1617: Rename snapshot variables to settings for clarity * Fix legacy bulk settings fallback * fix bug similar code
1 parent 26fef42 commit b986299

37 files changed

+823
-494
lines changed

apps/roam/src/components/DiscourseFloatingMenu.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { FeedbackWidget } from "./BirdEatsBugs";
1414
import { render as renderSettings } from "~/components/settings/Settings";
1515
import posthog from "posthog-js";
16-
import { getPersonalSetting } from "./settings/utils/accessors";
16+
import { type SettingsSnapshot } from "./settings/utils/accessors";
1717
import { PERSONAL_KEYS } from "./settings/utils/settingKeys";
1818

1919
type DiscourseFloatingMenuProps = {
@@ -118,26 +118,23 @@ export const showDiscourseFloatingMenu = () => {
118118

119119
export const installDiscourseFloatingMenu = (
120120
onLoadArgs: OnloadArgs,
121-
props: DiscourseFloatingMenuProps = {
122-
position: "bottom-right",
123-
theme: "bp3-light",
124-
buttonTheme: "bp3-light",
125-
},
121+
snapshot: SettingsSnapshot,
126122
) => {
127123
let floatingMenuAnchor = document.getElementById(ANCHOR_ID);
128124
if (!floatingMenuAnchor) {
129125
floatingMenuAnchor = document.createElement("div");
130126
floatingMenuAnchor.id = ANCHOR_ID;
131127
document.getElementById("app")?.appendChild(floatingMenuAnchor);
132128
}
133-
if (getPersonalSetting<boolean>([PERSONAL_KEYS.hideFeedbackButton])) {
129+
if (snapshot.personalSettings[PERSONAL_KEYS.hideFeedbackButton]) {
134130
floatingMenuAnchor.classList.add("hidden");
135131
}
132+
// eslint-disable-next-line react/no-deprecated
136133
ReactDOM.render(
137134
<DiscourseFloatingMenu
138-
position={props.position}
139-
theme={props.theme}
140-
buttonTheme={props.buttonTheme}
135+
position="bottom-right"
136+
theme="bp3-light"
137+
buttonTheme="bp3-light"
141138
onloadArgs={onLoadArgs}
142139
/>,
143140
floatingMenuAnchor,
@@ -148,6 +145,7 @@ export const removeDiscourseFloatingMenu = () => {
148145
const anchor = document.getElementById(ANCHOR_ID);
149146
if (anchor) {
150147
try {
148+
// eslint-disable-next-line react/no-deprecated
151149
ReactDOM.unmountComponentAtNode(anchor);
152150
} catch (e) {
153151
// no-op: unmount best-effort

apps/roam/src/components/DiscourseNodeMenu.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,9 @@ import { getNewDiscourseNodeText } from "~/utils/formatUtils";
2727
import { OnloadArgs } from "roamjs-components/types";
2828
import { formatHexColor } from "./settings/DiscourseNodeCanvasSettings";
2929
import posthog from "posthog-js";
30-
import {
31-
getPersonalSetting,
32-
setPersonalSetting,
33-
} from "~/components/settings/utils/accessors";
30+
import { setPersonalSetting } from "~/components/settings/utils/accessors";
3431
import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys";
32+
import type { PersonalSettings } from "~/components/settings/utils/zodSchema";
3533

3634
type Props = {
3735
textarea?: HTMLTextAreaElement;
@@ -420,19 +418,15 @@ export const comboToString = (combo: IKeyCombo): string => {
420418

421419
export const NodeMenuTriggerComponent = ({
422420
extensionAPI,
421+
initialValue,
423422
}: {
424423
extensionAPI: OnloadArgs["extensionAPI"];
424+
initialValue: PersonalSettings["Personal node menu trigger"];
425425
}) => {
426426
const inputRef = useRef<HTMLInputElement>(null);
427427
const [isActive, setIsActive] = useState(false);
428-
const [comboKey, setComboKey] = useState<IKeyCombo>(
429-
() =>
430-
getPersonalSetting<IKeyCombo>([
431-
PERSONAL_KEYS.personalNodeMenuTrigger,
432-
]) || {
433-
modifiers: 0,
434-
key: "",
435-
},
428+
const [comboKey, setComboKey] = useState<IKeyCombo>(() =>
429+
typeof initialValue === "object" ? initialValue : { modifiers: 0, key: "" },
436430
);
437431

438432
const handleKeyDown = useCallback(

apps/roam/src/components/DiscourseNodeSearchMenu.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,7 @@ import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes";
2525
import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression";
2626
import { Result } from "~/utils/types";
2727
import MiniSearch from "minisearch";
28-
import {
29-
getPersonalSetting,
30-
setPersonalSetting,
31-
} from "~/components/settings/utils/accessors";
28+
import { setPersonalSetting } from "~/components/settings/utils/accessors";
3229
import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys";
3330

3431
type Props = {
@@ -709,12 +706,14 @@ export const renderDiscourseNodeSearchMenu = (props: Props) => {
709706

710707
export const NodeSearchMenuTriggerSetting = ({
711708
onloadArgs,
709+
initialValue,
712710
}: {
713711
onloadArgs: OnloadArgs;
712+
initialValue: string;
714713
}) => {
715714
const extensionAPI = onloadArgs.extensionAPI;
716715
const [nodeSearchTrigger, setNodeSearchTrigger] = useState<string>(
717-
getPersonalSetting<string>([PERSONAL_KEYS.nodeSearchMenuTrigger]) ?? "@",
716+
() => initialValue,
718717
);
719718

720719
const handleNodeSearchTriggerChange = (

apps/roam/src/components/LeftSidebarView.tsx

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,18 @@ import { getLeftSidebarSettings } from "~/utils/getLeftSidebarSettings";
3636
import {
3737
getGlobalSetting,
3838
getPersonalSetting,
39+
getPersonalSettings,
3940
setGlobalSetting,
4041
setPersonalSetting,
42+
type SettingsSnapshot,
4143
} from "~/components/settings/utils/accessors";
4244
import {
4345
PERSONAL_KEYS,
4446
GLOBAL_KEYS,
4547
LEFT_SIDEBAR_KEYS,
4648
LEFT_SIDEBAR_SETTINGS_KEYS,
4749
} from "~/components/settings/utils/settingKeys";
48-
import type {
49-
LeftSidebarGlobalSettings,
50-
PersonalSection,
51-
} from "~/components/settings/utils/zodSchema";
50+
import type { LeftSidebarGlobalSettings } from "~/components/settings/utils/zodSchema";
5251
import { createBlock } from "roamjs-components/writes";
5352
import deleteBlock from "roamjs-components/writes/deleteBlock";
5453
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
@@ -112,7 +111,7 @@ const openTarget = async (e: React.MouseEvent, targetUid: string) => {
112111
}
113112
};
114113

115-
const toggleFoldedState = ({
114+
const toggleFoldedState = async ({
116115
isOpen,
117116
setIsOpen,
118117
folded,
@@ -130,23 +129,26 @@ const toggleFoldedState = ({
130129
const newFolded = !isOpen;
131130

132131
if (isOpen) {
133-
setIsOpen(false);
134-
if (folded.uid) {
135-
void deleteBlock(folded.uid);
136-
folded.uid = undefined;
137-
folded.value = false;
138-
}
132+
const children = getBasicTreeByParentUid(parentUid);
133+
await Promise.all(
134+
children
135+
.filter((c) => c.text === "Folded")
136+
.map((c) => deleteBlock(c.uid)),
137+
);
138+
folded.uid = undefined;
139+
folded.value = false;
139140
} else {
140-
setIsOpen(true);
141141
const newUid = window.roamAlphaAPI.util.generateUID();
142-
void createBlock({
142+
await createBlock({
143143
parentUid,
144144
node: { text: "Folded", uid: newUid },
145145
});
146146
folded.uid = newUid;
147147
folded.value = true;
148148
}
149149

150+
refreshConfigTree();
151+
150152
if (isGlobal) {
151153
setGlobalSetting(
152154
[
@@ -157,13 +159,20 @@ const toggleFoldedState = ({
157159
newFolded,
158160
);
159161
} else if (sectionIndex !== undefined) {
160-
const sections =
161-
getPersonalSetting<PersonalSection[]>([PERSONAL_KEYS.leftSidebar]) || [];
162+
const sections = [...getPersonalSettings()[PERSONAL_KEYS.leftSidebar]];
162163
if (sections[sectionIndex]) {
163-
sections[sectionIndex].Settings.Folded = newFolded;
164+
sections[sectionIndex] = {
165+
...sections[sectionIndex],
166+
Settings: {
167+
...sections[sectionIndex].Settings,
168+
Folded: newFolded,
169+
},
170+
};
164171
setPersonalSetting([PERSONAL_KEYS.leftSidebar], sections);
165172
}
166173
}
174+
175+
setIsOpen(newFolded);
167176
};
168177

169178
const SectionChildren = ({
@@ -225,7 +234,7 @@ const PersonalSectionItem = ({
225234
const handleChevronClick = () => {
226235
if (!section.settings) return;
227236

228-
toggleFoldedState({
237+
void toggleFoldedState({
229238
isOpen,
230239
setIsOpen,
231240
folded: section.settings.folded,
@@ -297,7 +306,7 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => {
297306
className="sidebar-title-button flex w-full items-center border-none bg-transparent py-1 pl-6 pr-2.5 font-semibold outline-none"
298307
onClick={() => {
299308
if (!isCollapsable || !config.settings) return;
300-
toggleFoldedState({
309+
void toggleFoldedState({
301310
isOpen,
302311
setIsOpen,
303312
folded: config.settings.folded,
@@ -328,14 +337,16 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => {
328337

329338
// TODO(ENG-1471): Remove old-system merge when migration complete — just use accessor values directly.
330339
// See mergeGlobalSectionWithAccessor/mergePersonalSectionsWithAccessor for why the merge exists.
331-
const buildConfig = (): LeftSidebarConfig => {
340+
const buildConfig = (snapshot?: SettingsSnapshot): LeftSidebarConfig => {
332341
// Read VALUES from accessor (handles flag routing + mismatch detection)
333-
const globalValues = getGlobalSetting<LeftSidebarGlobalSettings>([
334-
GLOBAL_KEYS.leftSidebar,
335-
]);
336-
const personalValues = getPersonalSetting<PersonalSection[]>([
337-
PERSONAL_KEYS.leftSidebar,
338-
]);
342+
const globalValues = snapshot
343+
? snapshot.globalSettings[GLOBAL_KEYS.leftSidebar]
344+
: getGlobalSetting<LeftSidebarGlobalSettings>([GLOBAL_KEYS.leftSidebar]);
345+
const personalValues = snapshot
346+
? snapshot.personalSettings[PERSONAL_KEYS.leftSidebar]
347+
: getPersonalSetting<
348+
ReturnType<typeof getPersonalSettings>[typeof PERSONAL_KEYS.leftSidebar]
349+
>([PERSONAL_KEYS.leftSidebar]);
339350

340351
// Read UIDs from old system (needed for fold CRUD during dual-write)
341352
const oldConfig = getCurrentLeftSidebarConfig();
@@ -356,8 +367,8 @@ const buildConfig = (): LeftSidebarConfig => {
356367
};
357368
};
358369

359-
export const useConfig = () => {
360-
const [config, setConfig] = useState(() => buildConfig());
370+
export const useConfig = (initialSnapshot?: SettingsSnapshot) => {
371+
const [config, setConfig] = useState(() => buildConfig(initialSnapshot));
361372
useEffect(() => {
362373
const handleUpdate = () => {
363374
setConfig(buildConfig());
@@ -496,8 +507,14 @@ const FavoritesPopover = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
496507
);
497508
};
498509

499-
const LeftSidebarView = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
500-
const { config } = useConfig();
510+
const LeftSidebarView = ({
511+
onloadArgs,
512+
initialSnapshot,
513+
}: {
514+
onloadArgs: OnloadArgs;
515+
initialSnapshot?: SettingsSnapshot;
516+
}) => {
517+
const { config } = useConfig(initialSnapshot);
501518

502519
return (
503520
<>
@@ -602,10 +619,15 @@ const migrateFavorites = async () => {
602619
refreshConfigTree();
603620
};
604621

605-
export const mountLeftSidebar = async (
606-
wrapper: HTMLElement,
607-
onloadArgs: OnloadArgs,
608-
): Promise<void> => {
622+
export const mountLeftSidebar = async ({
623+
wrapper,
624+
onloadArgs,
625+
initialSnapshot,
626+
}: {
627+
wrapper: HTMLElement;
628+
onloadArgs: OnloadArgs;
629+
initialSnapshot?: SettingsSnapshot;
630+
}): Promise<void> => {
609631
if (!wrapper) return;
610632

611633
const id = "dg-left-sidebar-root";
@@ -622,7 +644,14 @@ export const mountLeftSidebar = async (
622644
} else {
623645
root.className = "starred-pages";
624646
}
625-
ReactDOM.render(<LeftSidebarView onloadArgs={onloadArgs} />, root);
647+
// eslint-disable-next-line react/no-deprecated
648+
ReactDOM.render(
649+
<LeftSidebarView
650+
onloadArgs={onloadArgs}
651+
initialSnapshot={initialSnapshot}
652+
/>,
653+
root,
654+
);
626655
};
627656

628657
export default LeftSidebarView;

0 commit comments

Comments
 (0)