From f561e23b66b6af567b84c0e4810e52e61a03ceb9 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 16 Mar 2026 21:27:17 +0530 Subject: [PATCH 1/7] ENG-1470: Fix dual-read gaps found during flag-ON validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/roam/src/components/LeftSidebarView.tsx | 50 ++++++++------ .../src/components/settings/AdminPanel.tsx | 16 +++-- .../settings/LeftSidebarPersonalSettings.tsx | 11 +-- .../components/BlockPropSettingPanels.tsx | 17 +++-- .../components/settings/utils/accessors.ts | 40 ++++++++--- .../src/components/settings/utils/init.ts | 67 +++++++++++++++++++ apps/roam/src/utils/storedRelations.ts | 2 +- 7 files changed, 154 insertions(+), 49 deletions(-) diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx index be9e2e528..46ca39cc5 100644 --- a/apps/roam/src/components/LeftSidebarView.tsx +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -36,6 +36,7 @@ import { getLeftSidebarSettings } from "~/utils/getLeftSidebarSettings"; import { getGlobalSetting, getPersonalSetting, + getPersonalSettings, setGlobalSetting, setPersonalSetting, } from "~/components/settings/utils/accessors"; @@ -45,10 +46,7 @@ import { LEFT_SIDEBAR_KEYS, LEFT_SIDEBAR_SETTINGS_KEYS, } from "~/components/settings/utils/settingKeys"; -import type { - LeftSidebarGlobalSettings, - PersonalSection, -} from "~/components/settings/utils/zodSchema"; +import type { LeftSidebarGlobalSettings } from "~/components/settings/utils/zodSchema"; import { createBlock } from "roamjs-components/writes"; import deleteBlock from "roamjs-components/writes/deleteBlock"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; @@ -112,7 +110,7 @@ const openTarget = async (e: React.MouseEvent, targetUid: string) => { } }; -const toggleFoldedState = ({ +const toggleFoldedState = async ({ isOpen, setIsOpen, folded, @@ -130,16 +128,17 @@ const toggleFoldedState = ({ const newFolded = !isOpen; if (isOpen) { - setIsOpen(false); - if (folded.uid) { - void deleteBlock(folded.uid); - folded.uid = undefined; - folded.value = false; - } + const children = getBasicTreeByParentUid(parentUid); + await Promise.all( + children + .filter((c) => c.text === "Folded") + .map((c) => deleteBlock(c.uid)), + ); + folded.uid = undefined; + folded.value = false; } else { - setIsOpen(true); const newUid = window.roamAlphaAPI.util.generateUID(); - void createBlock({ + await createBlock({ parentUid, node: { text: "Folded", uid: newUid }, }); @@ -147,6 +146,8 @@ const toggleFoldedState = ({ folded.value = true; } + refreshConfigTree(); + if (isGlobal) { setGlobalSetting( [ @@ -157,13 +158,20 @@ const toggleFoldedState = ({ newFolded, ); } else if (sectionIndex !== undefined) { - const sections = - getPersonalSetting([PERSONAL_KEYS.leftSidebar]) || []; + const sections = [...getPersonalSettings()[PERSONAL_KEYS.leftSidebar]]; if (sections[sectionIndex]) { - sections[sectionIndex].Settings.Folded = newFolded; + sections[sectionIndex] = { + ...sections[sectionIndex], + Settings: { + ...sections[sectionIndex].Settings, + Folded: newFolded, + }, + }; setPersonalSetting([PERSONAL_KEYS.leftSidebar], sections); } } + + setIsOpen(newFolded); }; const SectionChildren = ({ @@ -225,7 +233,7 @@ const PersonalSectionItem = ({ const handleChevronClick = () => { if (!section.settings) return; - toggleFoldedState({ + void toggleFoldedState({ isOpen, setIsOpen, folded: section.settings.folded, @@ -297,7 +305,7 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { className="sidebar-title-button flex w-full items-center border-none bg-transparent py-1 pl-6 pr-2.5 font-semibold outline-none" onClick={() => { if (!isCollapsable || !config.settings) return; - toggleFoldedState({ + void toggleFoldedState({ isOpen, setIsOpen, folded: config.settings.folded, @@ -333,9 +341,9 @@ const buildConfig = (): LeftSidebarConfig => { const globalValues = getGlobalSetting([ GLOBAL_KEYS.leftSidebar, ]); - const personalValues = getPersonalSetting([ - PERSONAL_KEYS.leftSidebar, - ]); + const personalValues = getPersonalSetting< + ReturnType[typeof PERSONAL_KEYS.leftSidebar] + >([PERSONAL_KEYS.leftSidebar]); // Read UIDs from old system (needed for fold CRUD during dual-write) const oldConfig = getCurrentLeftSidebarConfig(); diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 5c4bd7317..3ab36c141 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -294,12 +294,15 @@ const FeatureFlagsTab = (): React.ReactElement => { if (checked) { setIsAlertOpen(true); } else { - if (suggestiveModeUid) { - void deleteBlock(suggestiveModeUid); - setSuggestiveModeUid(undefined); - } - setSuggestiveModeEnabled(false); - setFeatureFlag("Suggestive mode enabled", false); + void (async () => { + if (suggestiveModeUid) { + await deleteBlock(suggestiveModeUid); + setSuggestiveModeUid(undefined); + } + refreshConfigTree(); + setSuggestiveModeEnabled(false); + setFeatureFlag("Suggestive mode enabled", false); + })(); } }} labelElement={ @@ -321,6 +324,7 @@ const FeatureFlagsTab = (): React.ReactElement => { node: { text: "(BETA) Suggestive Mode Enabled" }, }).then((uid) => { setSuggestiveModeUid(uid); + refreshConfigTree(); setSuggestiveModeEnabled(true); setFeatureFlag("Suggestive mode enabled", true); setIsAlertOpen(false); diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index ed3426063..bc3974005 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -125,14 +125,9 @@ const SectionItem = memo( order: 0, node: { text: "Settings" }, }); - const foldedUid = await createBlock({ - parentUid: settingsUid, - order: 0, - node: { text: "Folded" }, - }); const truncateSettingUid = await createBlock({ parentUid: settingsUid, - order: 1, + order: 0, node: { text: "Truncate-result?", children: [{ text: "75" }] }, }); @@ -149,7 +144,7 @@ const SectionItem = memo( ...s, settings: { uid: settingsUid, - folded: { uid: foldedUid, value: false }, + folded: { uid: undefined, value: false }, truncateResult: { uid: truncateSettingUid, value: 75 }, }, childrenUid, @@ -167,7 +162,7 @@ const SectionItem = memo( ...s, settings: { uid: settingsUid, - folded: { uid: foldedUid, value: false }, + folded: { uid: undefined, value: false }, truncateResult: { uid: truncateSettingUid, value: 75 }, }, children: [], diff --git a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx index 81f8fc62f..c83ac5c50 100644 --- a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx +++ b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx @@ -18,6 +18,7 @@ import { import Description from "roamjs-components/components/Description"; import useSingleChildValue from "roamjs-components/components/ConfigPanels/useSingleChildValue"; import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import refreshConfigTree from "~/utils/refreshConfigTree"; import { getGlobalSetting, getPersonalSetting, @@ -143,8 +144,9 @@ const BaseTextPanel = ({ window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { if (errorRef.current) return; - setter(settingKeys, newValue); syncToBlock?.(newValue); + refreshConfigTree(); + setter(settingKeys, newValue); }, DEBOUNCE_MS); }; @@ -227,8 +229,9 @@ const BaseFlagPanel = ({ } setInternalValue(checked); - setter(settingKeys, checked); await syncFlagToBlock(checked); + refreshConfigTree(); + setter(settingKeys, checked); onChange?.(checked); }; @@ -276,8 +279,9 @@ const BaseNumberPanel = ({ const handleChange = (valueAsNumber: number) => { if (Number.isNaN(valueAsNumber)) return; setValue(valueAsNumber); - setter(settingKeys, valueAsNumber); syncToBlock?.(valueAsNumber); + refreshConfigTree(); + setter(settingKeys, valueAsNumber); onChange?.(valueAsNumber); }; @@ -323,8 +327,9 @@ const BaseSelectPanel = ({ const handleChange = (e: ChangeEvent) => { const newValue = e.target.value; setValue(newValue); - setter(settingKeys, newValue); syncToBlock?.(newValue); + refreshConfigTree(); + setter(settingKeys, newValue); }; return ( @@ -400,6 +405,7 @@ const BaseMultiTextPanel = ({ }, }); childUidsRef.current = [...childUidsRef.current, valueUid]; + refreshConfigTree(); } } }; @@ -408,7 +414,6 @@ const BaseMultiTextPanel = ({ // eslint-disable-next-line @typescript-eslint/naming-convention const newValues = values.filter((_, i) => i !== index); setValues(newValues); - setter(settingKeys, newValues); onChange?.(newValues); if (hasBlockSync) { @@ -420,7 +425,9 @@ const BaseMultiTextPanel = ({ // eslint-disable-next-line @typescript-eslint/naming-convention (_, i) => i !== index, ); + refreshConfigTree(); } + setter(settingKeys, newValues); }; const handleKeyDown = (e: React.KeyboardEvent) => { diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index ad53253e6..3b0942ccd 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -45,6 +45,24 @@ import { PERSONAL_KEYS, QUERY_KEYS, GLOBAL_KEYS } from "./settingKeys"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const deepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + const isEmpty = (v: unknown) => v === undefined || v === "" || v === false; + if (isEmpty(a) && isEmpty(b)) return true; + if (a == null || b == null) return a === b; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((v, i) => deepEqual(v, b[i])); + } + if (isRecord(a) && isRecord(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => k in b && deepEqual(a[k], b[k])); + } + return false; +}; + const unwrapSchema = (schema: z.ZodTypeAny): z.ZodTypeAny => { let current = schema; let didUnwrap = true; @@ -444,7 +462,10 @@ const getLegacyGlobalSetting = (keys: string[]): unknown => { }; const getLegacyQuerySettingByParentUid = (parentUid: string) => { - const scratchNode = getSubTree({ parentUid, key: "scratch" }); + const scratchNode = getSubTree({ + tree: getBasicTreeByParentUid(parentUid), + key: "scratch", + }); const conditionsNode = getSubTree({ tree: scratchNode.children, key: "conditions", @@ -514,6 +535,9 @@ const getLegacyDiscourseNodeSetting = ( }).children; const indexUid = getSubTree({ tree, key: "Index" }).uid; const specificationUid = getSubTree({ tree, key: "Specification" }).uid; + const specificationQuery = specificationUid + ? getLegacyQuerySettingByParentUid(specificationUid) + : DEFAULT_LEGACY_QUERY; const legacySettings = { type: nodeUid, @@ -542,11 +566,11 @@ const getLegacyDiscourseNodeSetting = ( : DEFAULT_LEGACY_QUERY, specification: { enabled: specificationUid - ? !!getSubTree({ parentUid: specificationUid, key: "enabled" }).uid + ? getBasicTreeByParentUid(specificationUid).some( + (c) => c.text === "enabled", + ) || specificationQuery.conditions.length > 0 : false, - query: specificationUid - ? getLegacyQuerySettingByParentUid(specificationUid) - : DEFAULT_LEGACY_QUERY, + query: specificationQuery, }, }; @@ -803,7 +827,7 @@ export const getGlobalSetting = ( const settings = getGlobalSettings(); const blockPropsValue = readPathValue(settings, keys); const legacyValue = getLegacyGlobalSetting(keys); - if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + if (!deepEqual(blockPropsValue, legacyValue)) { console.warn( `[DG Dual-Read] Mismatch at Global > ${formatSettingPath(keys)}`, { blockProps: blockPropsValue, legacy: legacyValue }, @@ -875,7 +899,7 @@ export const getPersonalSetting = ( const settings = getPersonalSettings(); const blockPropsValue = readPathValue(settings, keys); const legacyValue = getLegacyPersonalSetting(keys); - if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + if (!deepEqual(blockPropsValue, legacyValue)) { console.warn( `[DG Dual-Read] Mismatch at Personal > ${formatSettingPath(keys)}`, { blockProps: blockPropsValue, legacy: legacyValue }, @@ -959,7 +983,7 @@ export const getDiscourseNodeSetting = ( const settings = getDiscourseNodeSettings(nodeType); const blockPropsValue = settings ? readPathValue(settings, keys) : undefined; const legacyValue = getLegacyDiscourseNodeSetting(nodeType, keys); - if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + if (!deepEqual(blockPropsValue, legacyValue)) { console.warn( `[DG Dual-Read] Mismatch at Discourse Node (${nodeType}) > ${formatSettingPath(keys)}`, { blockProps: blockPropsValue, legacy: legacyValue }, diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index 86e19ed82..38c2731e8 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -5,6 +5,7 @@ import setBlockProps from "~/utils/setBlockProps"; import getBlockProps from "~/utils/getBlockProps"; import type { json } from "~/utils/getBlockProps"; import INITIAL_NODE_VALUES from "~/data/defaultDiscourseNodes"; +import DEFAULT_RELATION_VALUES from "~/data/defaultDiscourseRelations"; import DEFAULT_RELATIONS_BLOCK_PROPS from "~/components/settings/data/defaultRelationsBlockProps"; import { getAllDiscourseNodes } from "./accessors"; import { @@ -72,6 +73,70 @@ const buildBlockMap = (pageUid: string): Record => { return blockMap; }; +const ensureLegacyConfigBlocks = async (pageUid: string): Promise => { + const pageBlockMap = buildBlockMap(pageUid); + + await ensureBlocksExist( + pageUid, + ["trigger", "grammar", "export", "Suggestive Mode", "Left Sidebar"], + pageBlockMap, + ); + + const triggerMap = buildBlockMap(pageBlockMap["trigger"]); + if (Object.keys(triggerMap).length === 0) { + await createBlock({ + parentUid: pageBlockMap["trigger"], + node: { text: "\\" }, + }); + } + + const grammarMap = buildBlockMap(pageBlockMap["grammar"]); + await ensureBlocksExist(pageBlockMap["grammar"], ["relations"], grammarMap); + const relationsChildren = getShallowTreeByParentUid(grammarMap["relations"]); + if (relationsChildren.length === 0) { + for (const relation of DEFAULT_RELATION_VALUES) { + await createBlock({ + parentUid: grammarMap["relations"], + node: relation, + }); + } + } + + const suggestiveMap = buildBlockMap(pageBlockMap["Suggestive Mode"]); + await ensureBlocksExist( + pageBlockMap["Suggestive Mode"], + ["Page Groups"], + suggestiveMap, + ); + + const leftSidebarMap = buildBlockMap(pageBlockMap["Left Sidebar"]); + await ensureBlocksExist( + pageBlockMap["Left Sidebar"], + ["Global-Section"], + leftSidebarMap, + ); + const globalSectionMap = buildBlockMap(leftSidebarMap["Global-Section"]); + await ensureBlocksExist( + leftSidebarMap["Global-Section"], + ["Children", "Settings"], + globalSectionMap, + ); + + const exportMap = buildBlockMap(pageBlockMap["export"]); + await ensureBlocksExist( + pageBlockMap["export"], + ["max filename length"], + exportMap, + ); + const maxFilenameMap = buildBlockMap(exportMap["max filename length"]); + if (Object.keys(maxFilenameMap).length === 0) { + await createBlock({ + parentUid: exportMap["max filename length"], + node: { text: "64" }, + }); + } +}; + const initializeSettingsBlockProps = ( pageUid: string, blockMap: Record, @@ -118,6 +183,8 @@ const initSettingsPageBlocks = async (): Promise> => { const topLevelBlocks = getTopLevelBlockPropsConfig().map(({ key }) => key); await ensureBlocksExist(pageUid, topLevelBlocks, blockMap); + await ensureLegacyConfigBlocks(pageUid); + initializeSettingsBlockProps(pageUid, blockMap); return blockMap; diff --git a/apps/roam/src/utils/storedRelations.ts b/apps/roam/src/utils/storedRelations.ts index c32522c43..4397b4c0f 100644 --- a/apps/roam/src/utils/storedRelations.ts +++ b/apps/roam/src/utils/storedRelations.ts @@ -3,8 +3,8 @@ // ENG-1521: Update internal terminology to use "stored" instead of "reified" import { USE_STORED_RELATIONS } from "~/data/userSettings"; +import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/data/constants"; import { getSetting, setSetting } from "./extensionSettings"; -import { DISCOURSE_CONFIG_PAGE_TITLE } from "./renderNodeConfigPage"; const INSTALL_CUTOFF = Date.parse("2026-03-01T00:00:00.000Z"); From 94cf1e2d9ed3dbd915e80a1933a42d8ec30988d1 Mon Sep 17 00:00:00 2001 From: sid597 Date: Tue, 17 Mar 2026 01:45:41 +0530 Subject: [PATCH 2/7] Fix dual-read mismatches and ZodError on discourse node parse --- apps/roam/src/components/settings/AdminPanel.tsx | 2 +- .../settings/components/BlockPropSettingPanels.tsx | 5 ++++- apps/roam/src/components/settings/utils/accessors.ts | 12 ++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 3ab36c141..31d3f4567 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -277,7 +277,7 @@ const FeatureFlagsTab = (): React.ReactElement => { }, []); const [suggestiveModeEnabled, setSuggestiveModeEnabled] = useState( - getFeatureFlag("Suggestive mode enabled"), + () => getFeatureFlag("Suggestive mode enabled"), ); const [suggestiveModeUid, setSuggestiveModeUid] = useState( legacySuggestiveModeMeta.suggestiveModeEnabledUid, diff --git a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx index c83ac5c50..f535b0963 100644 --- a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx +++ b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx @@ -232,7 +232,10 @@ const BaseFlagPanel = ({ await syncFlagToBlock(checked); refreshConfigTree(); setter(settingKeys, checked); - onChange?.(checked); + // Delay onChange to let the async Roam block.update flush before + // the re-render reads block props via dual-read comparison. + // TODO(ENG-1471): remove with dual-read cleanup + setTimeout(() => onChange?.(checked), 100); }; return ( diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 3b0942ccd..655f155a7 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -568,7 +568,7 @@ const getLegacyDiscourseNodeSetting = ( enabled: specificationUid ? getBasicTreeByParentUid(specificationUid).some( (c) => c.text === "enabled", - ) || specificationQuery.conditions.length > 0 + ) : false, query: specificationQuery, }, @@ -936,11 +936,13 @@ export const setPersonalSetting = (keys: string[], value: json): void => { }); }; -const getRawDiscourseNodeBlockProps = (nodeType: string): json | undefined => { +const getRawDiscourseNodeBlockProps = ( + nodeType: string, +): Record | undefined => { let pageUid = nodeType; let blockProps = getBlockPropsByUid(pageUid, []); - if (!blockProps || Object.keys(blockProps).length === 0) { + if (!isRecord(blockProps) || Object.keys(blockProps).length === 0) { const lookedUpUid = getPageUidByPageTitle( `${DISCOURSE_NODE_PAGE_PREFIX}${nodeType}`, ); @@ -950,7 +952,9 @@ const getRawDiscourseNodeBlockProps = (nodeType: string): json | undefined => { } } - return blockProps; + return isRecord(blockProps) && Object.keys(blockProps).length > 0 + ? (blockProps as Record) + : undefined; }; export const getDiscourseNodeSettings = ( From e2d140eb985ef7d21d0d59a42e5cadfdc4dbf852 Mon Sep 17 00:00:00 2001 From: sid597 Date: Tue, 17 Mar 2026 11:31:22 +0530 Subject: [PATCH 3/7] Fix dual-read mismatches: alias timing, key-image re-render, deepEqual null --- .../components/settings/DiscourseNodeCanvasSettings.tsx | 2 +- .../settings/components/BlockPropSettingPanels.tsx | 9 ++++----- apps/roam/src/components/settings/utils/accessors.ts | 3 ++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/roam/src/components/settings/DiscourseNodeCanvasSettings.tsx b/apps/roam/src/components/settings/DiscourseNodeCanvasSettings.tsx index 692458470..de126e065 100644 --- a/apps/roam/src/components/settings/DiscourseNodeCanvasSettings.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeCanvasSettings.tsx @@ -142,13 +142,13 @@ const DiscourseNodeCanvasSettings = ({ settingKeys={[DISCOURSE_NODE_KEYS.canvasSettings, CANVAS_KEYS.keyImage]} initialValue={isKeyImage} onChange={(checked) => { - setIsKeyImage(checked); if (checked && !keyImageOption) setKeyImageOption("first-image"); void setInputSetting({ blockUid: uid, key: "key-image", value: checked ? "true" : "false", }); + setTimeout(() => setIsKeyImage(checked), 100); }} /> { if (errorRef.current) return; syncToBlock?.(newValue); - refreshConfigTree(); - setter(settingKeys, newValue); + setTimeout(() => { + refreshConfigTree(); + setter(settingKeys, newValue); + }, 100); }, DEBOUNCE_MS); }; @@ -232,9 +234,6 @@ const BaseFlagPanel = ({ await syncFlagToBlock(checked); refreshConfigTree(); setter(settingKeys, checked); - // Delay onChange to let the async Roam block.update flush before - // the re-render reads block props via dual-read comparison. - // TODO(ENG-1471): remove with dual-read cleanup setTimeout(() => onChange?.(checked), 100); }; diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 655f155a7..808be1a25 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -47,7 +47,8 @@ const isRecord = (value: unknown): value is Record => const deepEqual = (a: unknown, b: unknown): boolean => { if (a === b) return true; - const isEmpty = (v: unknown) => v === undefined || v === "" || v === false; + const isEmpty = (v: unknown) => + v === undefined || v === null || v === "" || v === false; if (isEmpty(a) && isEmpty(b)) return true; if (a == null || b == null) return a === b; if (Array.isArray(a) && Array.isArray(b)) { From 8830ade8ae4a7559accd99c0cab993d7f1f5ccc2 Mon Sep 17 00:00:00 2001 From: sid597 Date: Tue, 17 Mar 2026 11:36:37 +0530 Subject: [PATCH 4/7] Fix prettier formatting --- apps/roam/src/components/settings/AdminPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 31d3f4567..ae9e0d7d7 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -276,8 +276,8 @@ const FeatureFlagsTab = (): React.ReactElement => { }; }, []); - const [suggestiveModeEnabled, setSuggestiveModeEnabled] = useState( - () => getFeatureFlag("Suggestive mode enabled"), + const [suggestiveModeEnabled, setSuggestiveModeEnabled] = useState(() => + getFeatureFlag("Suggestive mode enabled"), ); const [suggestiveModeUid, setSuggestiveModeUid] = useState( legacySuggestiveModeMeta.suggestiveModeEnabledUid, From 86cf3f0173c28099816a5ccabbe2409848fdf47d Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Thu, 2 Apr 2026 00:28:56 +0530 Subject: [PATCH 5/7] ENG-1574: Add dual-read console logs to setting setters (#914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../components/settings/utils/accessors.ts | 2 +- .../src/components/settings/utils/init.ts | 95 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 808be1a25..2eb2c0b2a 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -45,7 +45,7 @@ import { PERSONAL_KEYS, QUERY_KEYS, GLOBAL_KEYS } from "./settingKeys"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const deepEqual = (a: unknown, b: unknown): boolean => { +export const deepEqual = (a: unknown, b: unknown): boolean => { if (a === b) return true; const isEmpty = (v: unknown) => v === undefined || v === null || v === "" || v === false; diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index 38c2731e8..07a15f1d6 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -7,7 +7,19 @@ import type { json } from "~/utils/getBlockProps"; import INITIAL_NODE_VALUES from "~/data/defaultDiscourseNodes"; import DEFAULT_RELATION_VALUES from "~/data/defaultDiscourseRelations"; import DEFAULT_RELATIONS_BLOCK_PROPS from "~/components/settings/data/defaultRelationsBlockProps"; -import { getAllDiscourseNodes } from "./accessors"; +import { + getAllDiscourseNodes, + isNewSettingsStoreEnabled, + deepEqual, + getFeatureFlags, + getGlobalSettings, + getPersonalSettings, + getDiscourseNodeSettings, + readAllLegacyFeatureFlags, + readAllLegacyGlobalSettings, + readAllLegacyPersonalSettings, + readAllLegacyDiscourseNodeSettings, +} from "./accessors"; import { migrateGraphLevel, migratePersonalSettings, @@ -324,10 +336,91 @@ export type InitSchemaResult = { nodePageUids: Record; }; +const logDualReadComparison = (): void => { + if (!isNewSettingsStoreEnabled()) return; + + const legacyFlags = readAllLegacyFeatureFlags(); + const blockFlags = getFeatureFlags(); + const omitStoreFlag = ( + flags: Record, + ): Record => + Object.fromEntries( + Object.entries(flags).filter(([k]) => k !== "Use new settings store"), + ); + const flagsMatch = deepEqual( + omitStoreFlag(blockFlags), + omitStoreFlag(legacyFlags), + ); + + const legacyGlobal = readAllLegacyGlobalSettings(); + const blockGlobal = getGlobalSettings(); + const globalMatch = deepEqual(blockGlobal, legacyGlobal); + + const legacyPersonal = readAllLegacyPersonalSettings(); + const blockPersonal = getPersonalSettings(); + const personalMatch = deepEqual(blockPersonal, legacyPersonal); + + const nodes = getAllDiscourseNodes(); + const nodeResults: { + name: string; + match: boolean; + legacy: unknown; + blockProps: unknown; + }[] = []; + for (const node of nodes) { + const legacy = readAllLegacyDiscourseNodeSettings(node.type, node.text); + const blockProps = getDiscourseNodeSettings(node.type); + nodeResults.push({ + name: node.text, + match: deepEqual(blockProps, legacy), + legacy, + blockProps, + }); + } + + const mismatches = [ + !flagsMatch && "Feature Flags", + !globalMatch && "Global", + !personalMatch && "Personal", + ...nodeResults.filter((n) => !n.match).map((n) => n.name), + ].filter((x): x is string => Boolean(x)); + + const summary = + mismatches.length === 0 + ? "All settings match" + : `(${mismatches.length}) Settings don't match: ${mismatches.join(", ")}`; + + const nodeMap = (key: "legacy" | "blockProps") => + Object.fromEntries(nodeResults.map((n) => [n.name, n[key]])); + + /* eslint-disable @typescript-eslint/naming-convention */ + console.log(`[DG Dual-Read] ${summary}`); + console.log("[DG Dual-Read] Legacy:", { + "Feature Flags": legacyFlags, + Global: legacyGlobal, + Personal: legacyPersonal, + ...nodeMap("legacy"), + }); + console.log("[DG Dual-Read] Block props:", { + "Feature Flags": blockFlags, + Global: blockGlobal, + Personal: blockPersonal, + ...nodeMap("blockProps"), + }); + /* eslint-enable @typescript-eslint/naming-convention */ +}; + export const initSchema = async (): Promise => { const blockUids = await initSettingsPageBlocks(); await migrateGraphLevel(blockUids); const nodePageUids = await initDiscourseNodePages(); await migratePersonalSettings(blockUids); + try { + logDualReadComparison(); + } catch (e) { + console.warn("[DG Dual-Read] Comparison failed:", e); + } + (window as unknown as Record).dgDualReadLog = + logDualReadComparison; return { blockUids, nodePageUids }; }; From d8ff16f1173d949c3c9ead523398dfcf95cfa151 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Wed, 15 Apr 2026 21:25:00 +0530 Subject: [PATCH 6/7] Eng 1616 add getconfigtree equivalent for block pros on init (#944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (343dc117) 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 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 --- .../src/components/DiscourseFloatingMenu.tsx | 18 ++-- apps/roam/src/components/LeftSidebarView.tsx | 53 +++++++---- .../components/settings/QueryPagesPanel.tsx | 13 ++- .../components/settings/utils/accessors.ts | 61 +++++++++++- .../src/components/settings/utils/init.ts | 8 +- .../utils/migrateLegacyToBlockProps.ts | 10 +- apps/roam/src/index.ts | 44 +++++---- apps/roam/src/utils/findDiscourseNode.ts | 8 +- apps/roam/src/utils/getDiscourseNodes.ts | 14 ++- .../src/utils/getDiscourseRelationLabels.ts | 17 +++- apps/roam/src/utils/getDiscourseRelations.ts | 10 +- .../src/utils/initializeDiscourseNodes.ts | 7 +- .../utils/initializeObserversAndListeners.ts | 95 +++++++++++++------ apps/roam/src/utils/isCanvasPage.ts | 27 +++++- apps/roam/src/utils/isQueryPage.ts | 11 ++- apps/roam/src/utils/posthog.ts | 17 +--- apps/roam/src/utils/refreshConfigTree.ts | 7 +- .../registerDiscourseDatalogTranslators.ts | 7 +- apps/roam/src/utils/setQueryPages.ts | 20 ++-- 19 files changed, 299 insertions(+), 148 deletions(-) diff --git a/apps/roam/src/components/DiscourseFloatingMenu.tsx b/apps/roam/src/components/DiscourseFloatingMenu.tsx index 3a7e337b9..a96c33f1b 100644 --- a/apps/roam/src/components/DiscourseFloatingMenu.tsx +++ b/apps/roam/src/components/DiscourseFloatingMenu.tsx @@ -13,7 +13,7 @@ import { import { FeedbackWidget } from "./BirdEatsBugs"; import { render as renderSettings } from "~/components/settings/Settings"; import posthog from "posthog-js"; -import { getPersonalSetting } from "./settings/utils/accessors"; +import { type SettingsSnapshot } from "./settings/utils/accessors"; import { PERSONAL_KEYS } from "./settings/utils/settingKeys"; type DiscourseFloatingMenuProps = { @@ -118,11 +118,7 @@ export const showDiscourseFloatingMenu = () => { export const installDiscourseFloatingMenu = ( onLoadArgs: OnloadArgs, - props: DiscourseFloatingMenuProps = { - position: "bottom-right", - theme: "bp3-light", - buttonTheme: "bp3-light", - }, + snapshot: SettingsSnapshot, ) => { let floatingMenuAnchor = document.getElementById(ANCHOR_ID); if (!floatingMenuAnchor) { @@ -130,14 +126,15 @@ export const installDiscourseFloatingMenu = ( floatingMenuAnchor.id = ANCHOR_ID; document.getElementById("app")?.appendChild(floatingMenuAnchor); } - if (getPersonalSetting([PERSONAL_KEYS.hideFeedbackButton])) { + if (snapshot.personalSettings[PERSONAL_KEYS.hideFeedbackButton]) { floatingMenuAnchor.classList.add("hidden"); } + // eslint-disable-next-line react/no-deprecated ReactDOM.render( , floatingMenuAnchor, @@ -148,6 +145,7 @@ export const removeDiscourseFloatingMenu = () => { const anchor = document.getElementById(ANCHOR_ID); if (anchor) { try { + // eslint-disable-next-line react/no-deprecated ReactDOM.unmountComponentAtNode(anchor); } catch (e) { // no-op: unmount best-effort diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx index 46ca39cc5..b5793707c 100644 --- a/apps/roam/src/components/LeftSidebarView.tsx +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -39,6 +39,7 @@ import { getPersonalSettings, setGlobalSetting, setPersonalSetting, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS, @@ -336,14 +337,16 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { // TODO(ENG-1471): Remove old-system merge when migration complete — just use accessor values directly. // See mergeGlobalSectionWithAccessor/mergePersonalSectionsWithAccessor for why the merge exists. -const buildConfig = (): LeftSidebarConfig => { +const buildConfig = (snapshot?: SettingsSnapshot): LeftSidebarConfig => { // Read VALUES from accessor (handles flag routing + mismatch detection) - const globalValues = getGlobalSetting([ - GLOBAL_KEYS.leftSidebar, - ]); - const personalValues = getPersonalSetting< - ReturnType[typeof PERSONAL_KEYS.leftSidebar] - >([PERSONAL_KEYS.leftSidebar]); + const globalValues = snapshot + ? snapshot.globalSettings[GLOBAL_KEYS.leftSidebar] + : getGlobalSetting([GLOBAL_KEYS.leftSidebar]); + const personalValues = snapshot + ? snapshot.personalSettings[PERSONAL_KEYS.leftSidebar] + : getPersonalSetting< + ReturnType[typeof PERSONAL_KEYS.leftSidebar] + >([PERSONAL_KEYS.leftSidebar]); // Read UIDs from old system (needed for fold CRUD during dual-write) const oldConfig = getCurrentLeftSidebarConfig(); @@ -364,8 +367,8 @@ const buildConfig = (): LeftSidebarConfig => { }; }; -export const useConfig = () => { - const [config, setConfig] = useState(() => buildConfig()); +export const useConfig = (initialSnapshot?: SettingsSnapshot) => { + const [config, setConfig] = useState(() => buildConfig(initialSnapshot)); useEffect(() => { const handleUpdate = () => { setConfig(buildConfig()); @@ -504,8 +507,14 @@ const FavoritesPopover = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { ); }; -const LeftSidebarView = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { - const { config } = useConfig(); +const LeftSidebarView = ({ + onloadArgs, + initialSnapshot, +}: { + onloadArgs: OnloadArgs; + initialSnapshot?: SettingsSnapshot; +}) => { + const { config } = useConfig(initialSnapshot); return ( <> @@ -610,10 +619,15 @@ const migrateFavorites = async () => { refreshConfigTree(); }; -export const mountLeftSidebar = async ( - wrapper: HTMLElement, - onloadArgs: OnloadArgs, -): Promise => { +export const mountLeftSidebar = async ({ + wrapper, + onloadArgs, + initialSnapshot, +}: { + wrapper: HTMLElement; + onloadArgs: OnloadArgs; + initialSnapshot?: SettingsSnapshot; +}): Promise => { if (!wrapper) return; const id = "dg-left-sidebar-root"; @@ -630,7 +644,14 @@ export const mountLeftSidebar = async ( } else { root.className = "starred-pages"; } - ReactDOM.render(, root); + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( + , + root, + ); }; export default LeftSidebarView; diff --git a/apps/roam/src/components/settings/QueryPagesPanel.tsx b/apps/roam/src/components/settings/QueryPagesPanel.tsx index 49122bf5e..c6f792dca 100644 --- a/apps/roam/src/components/settings/QueryPagesPanel.tsx +++ b/apps/roam/src/components/settings/QueryPagesPanel.tsx @@ -5,6 +5,7 @@ import type { OnloadArgs } from "roamjs-components/types"; import { getPersonalSetting, setPersonalSetting, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS, @@ -13,11 +14,13 @@ import { // Legacy extensionAPI stored query-pages as string | string[] | Record. // Coerce to string[] for backward compatibility with old stored formats. -export const getQueryPages = (): string[] => { - const value = getPersonalSetting>([ - PERSONAL_KEYS.query, - QUERY_KEYS.queryPages, - ]); +export const getQueryPages = (snapshot?: SettingsSnapshot): string[] => { + const value: string[] | string | Record | undefined = snapshot + ? snapshot.personalSettings[PERSONAL_KEYS.query][QUERY_KEYS.queryPages] + : getPersonalSetting>([ + PERSONAL_KEYS.query, + QUERY_KEYS.queryPages, + ]); return typeof value === "string" ? [value] : Array.isArray(value) diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 2eb2c0b2a..775945a32 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -863,8 +863,10 @@ export const setGlobalSetting = (keys: string[], value: json): void => { }); }; -export const getAllRelations = (): DiscourseRelation[] => { - const settings = getGlobalSettings(); +export const getAllRelations = ( + snapshot?: SettingsSnapshot, +): DiscourseRelation[] => { + const settings = snapshot ? snapshot.globalSettings : getGlobalSettings(); return Object.entries(settings.Relations).flatMap(([id, relation]) => relation.ifConditions.map((ifCondition) => ({ @@ -909,6 +911,61 @@ export const getPersonalSetting = ( return blockPropsValue as T | undefined; }; +export type SettingsSnapshot = { + featureFlags: FeatureFlags; + globalSettings: GlobalSettings; + personalSettings: PersonalSettings; +}; + +export const bulkReadSettings = (): SettingsSnapshot => { + const pageResult = window.roamAlphaAPI.pull( + "[{:block/children [:block/string :block/props]}]", + [":node/title", DG_BLOCK_PROP_SETTINGS_PAGE_TITLE], + ) as Record | null; + + const children = (pageResult?.[":block/children"] ?? []) as Record< + string, + json + >[]; + const personalKey = getPersonalSettingsKey(); + let featureFlagsProps: json = {}; + let globalProps: json = {}; + let personalProps: json = {}; + + for (const child of children) { + const text = child[":block/string"]; + if (typeof text !== "string") continue; + const rawBlockProps = child[":block/props"]; + const blockProps = + rawBlockProps && typeof rawBlockProps === "object" + ? normalizeProps(rawBlockProps) + : {}; + if (text === TOP_LEVEL_BLOCK_PROP_KEYS.featureFlags) { + featureFlagsProps = blockProps; + } else if (text === TOP_LEVEL_BLOCK_PROP_KEYS.global) { + globalProps = blockProps; + } else if (text === personalKey) { + personalProps = blockProps; + } + } + + const featureFlags = FeatureFlagsSchema.parse(featureFlagsProps || {}); + + if (!featureFlags["Use new settings store"]) { + return { + featureFlags, + globalSettings: readAllLegacyGlobalSettings() as GlobalSettings, + personalSettings: readAllLegacyPersonalSettings() as PersonalSettings, + }; + } + + return { + featureFlags, + globalSettings: GlobalSettingsSchema.parse(globalProps || {}), + personalSettings: PersonalSettingsSchema.parse(personalProps || {}), + }; +}; + export const setPersonalSetting = (keys: string[], value: json): void => { if (keys.length === 0) { internalError({ diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index 07a15f1d6..3720f939e 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -336,6 +336,9 @@ export type InitSchemaResult = { nodePageUids: Record; }; +// On-demand dual-read comparison. Not called automatically on init — +// invoke from the console via window.dgDualReadLog() to inspect the legacy +// settings tree vs. the block-prop store. const logDualReadComparison = (): void => { if (!isNewSettingsStoreEnabled()) return; @@ -415,11 +418,6 @@ export const initSchema = async (): Promise => { await migrateGraphLevel(blockUids); const nodePageUids = await initDiscourseNodePages(); await migratePersonalSettings(blockUids); - try { - logDualReadComparison(); - } catch (e) { - console.warn("[DG Dual-Read] Comparison failed:", e); - } (window as unknown as Record).dgDualReadLog = logDualReadComparison; return { blockUids, nodePageUids }; diff --git a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts index 8ca1fe496..0e0fa45c9 100644 --- a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts +++ b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts @@ -1,7 +1,6 @@ import getBlockProps from "~/utils/getBlockProps"; import type { json } from "~/utils/getBlockProps"; import setBlockProps from "~/utils/setBlockProps"; -import getBlockUidByTextOnPage from "roamjs-components/queries/getBlockUidByTextOnPage"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import { createBlock } from "roamjs-components/writes"; import { getSetting, setSetting } from "~/utils/extensionSettings"; @@ -29,11 +28,8 @@ const GRAPH_MIGRATION_MARKER = "Block props migrated"; const PERSONAL_MIGRATION_MARKER = "dg-personal-settings-migrated"; const MAX_ERROR_CONTEXT_LENGTH = 5000; -const hasGraphMigrationMarker = (): boolean => - !!getBlockUidByTextOnPage({ - text: GRAPH_MIGRATION_MARKER, - title: DG_BLOCK_PROP_SETTINGS_PAGE_TITLE, - }); +const hasGraphMigrationMarker = (blockMap: Record): boolean => + !!blockMap[GRAPH_MIGRATION_MARKER]; const isPropsValid = ( schema: z.ZodTypeAny, @@ -182,7 +178,7 @@ export const migrateGraphLevel = async ( return; } - if (hasGraphMigrationMarker()) { + if (hasGraphMigrationMarker(blockUids)) { console.log(`${LOG_PREFIX} graph-level: skipped (already migrated)`); return; } diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index ff8e95d9a..68d404ede 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -34,10 +34,7 @@ import { import { initPluginTimer } from "./utils/pluginTimer"; import { initPostHog } from "./utils/posthog"; import { initSchema } from "./components/settings/utils/init"; -import { - getFeatureFlag, - getPersonalSetting, -} from "./components/settings/utils/accessors"; +import { bulkReadSettings } from "./components/settings/utils/accessors"; import { PERSONAL_KEYS } from "./components/settings/utils/settingKeys"; import { setupPullWatchOnSettingsPage } from "./components/settings/utils/pullWatchers"; import { @@ -49,12 +46,11 @@ import { mountLeftSidebar } from "./components/LeftSidebarView"; export const DEFAULT_CANVAS_PAGE_FORMAT = "Canvas/*"; export default runExtension(async (onloadArgs) => { - const isEncrypted = window.roamAlphaAPI.graph.isEncrypted; - const isOffline = window.roamAlphaAPI.graph.type === "offline"; - const disallowDiagnostics = getPersonalSetting([ - PERSONAL_KEYS.disableProductDiagnostics, - ]); - if (!isEncrypted && !isOffline && !disallowDiagnostics) { + const pluginLoadStart = performance.now(); + + const settings = bulkReadSettings(); + + if (!settings.personalSettings[PERSONAL_KEYS.disableProductDiagnostics]) { initPostHog(); } @@ -81,14 +77,16 @@ export default runExtension(async (onloadArgs) => { initPluginTimer(); - await initializeDiscourseNodes(); - refreshConfigTree(); + await initializeDiscourseNodes(settings); + + refreshConfigTree(settings); addGraphViewNodeStyling(); registerCommandPaletteCommands(onloadArgs); createSettingsPanel(onloadArgs); registerSmartBlock(onloadArgs); - setInitialQueryPages(onloadArgs); + + setInitialQueryPages(onloadArgs, settings); const style = addStyle(styles); const discourseGraphStyle = addStyle(discourseGraphStyles); @@ -96,16 +94,18 @@ export default runExtension(async (onloadArgs) => { const discourseFloatingMenuStyle = addStyle(discourseFloatingMenuStyles); // Add streamline styling only if enabled - const isStreamlineStylingEnabled = getPersonalSetting([ - PERSONAL_KEYS.streamlineStyling, - ]); + const isStreamlineStylingEnabled = + settings.personalSettings[PERSONAL_KEYS.streamlineStyling]; let streamlineStyleElement: HTMLStyleElement | null = null; if (isStreamlineStylingEnabled) { streamlineStyleElement = addStyle(streamlineStyling); streamlineStyleElement.id = "streamline-styling"; } - const { observers, listeners, cleanups } = initObservers({ onloadArgs }); + const { observers, listeners, cleanups } = initObservers({ + onloadArgs, + settings, + }); const { pageActionListener, hashChangeListener, @@ -119,7 +119,7 @@ export default runExtension(async (onloadArgs) => { document.addEventListener("input", discourseNodeSearchTriggerListener); document.addEventListener("selectionchange", nodeCreationPopoverListener); - if (getFeatureFlag("Suggestive mode enabled")) { + if (settings.featureFlags["Suggestive mode enabled"]) { initializeSupabaseSync(); } @@ -150,7 +150,7 @@ export default runExtension(async (onloadArgs) => { getDiscourseNodes: getDiscourseNodes, }; - installDiscourseFloatingMenu(onloadArgs); + installDiscourseFloatingMenu(onloadArgs, settings); const leftSidebarScript = document.querySelector( 'script#roam-left-sidebar[src="https://sid597.github.io/roam-left-sidebar/js/main.js"]', @@ -176,7 +176,7 @@ export default runExtension(async (onloadArgs) => { if (!wrapper) return; if (enabled) { wrapper.style.padding = "0"; - void mountLeftSidebar(wrapper, onloadArgs); + void mountLeftSidebar({ wrapper, onloadArgs }); } else { const root = wrapper.querySelector("#dg-left-sidebar-root"); if (root) { @@ -192,6 +192,10 @@ export default runExtension(async (onloadArgs) => { const { blockUids } = await initSchema(); const cleanupPullWatchers = setupPullWatchOnSettingsPage(blockUids); + console.log( + `[DG Plugin] Total load: ${Math.round(performance.now() - pluginLoadStart)}ms`, + ); + return { elements: [ style, diff --git a/apps/roam/src/utils/findDiscourseNode.ts b/apps/roam/src/utils/findDiscourseNode.ts index 345d0b41d..e0af95981 100644 --- a/apps/roam/src/utils/findDiscourseNode.ts +++ b/apps/roam/src/utils/findDiscourseNode.ts @@ -1,23 +1,27 @@ import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import matchDiscourseNode from "./matchDiscourseNode"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; const discourseNodeTypeCache: Record = {}; const findDiscourseNode = ({ uid, title, - nodes = getDiscourseNodes(), + nodes, + snapshot, }: { uid: string; title?: string; nodes?: DiscourseNode[]; + snapshot?: SettingsSnapshot; }): DiscourseNode | false => { if (typeof discourseNodeTypeCache[uid] !== "undefined") { return discourseNodeTypeCache[uid]; } + const resolvedNodes = nodes ?? getDiscourseNodes(undefined, snapshot); const matchingNode = - nodes.find((node) => + resolvedNodes.find((node) => title === undefined ? matchDiscourseNode({ ...node, uid }) : matchDiscourseNode({ ...node, title }), diff --git a/apps/roam/src/utils/getDiscourseNodes.ts b/apps/roam/src/utils/getDiscourseNodes.ts index cd83f940f..765d12422 100644 --- a/apps/roam/src/utils/getDiscourseNodes.ts +++ b/apps/roam/src/utils/getDiscourseNodes.ts @@ -3,6 +3,7 @@ import getSubTree from "roamjs-components/util/getSubTree"; import { isNewSettingsStoreEnabled, getAllDiscourseNodes, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import discourseConfigRef from "./discourseConfigRef"; import getDiscourseRelations from "./getDiscourseRelations"; @@ -106,9 +107,16 @@ const getUidAndBooleanSetting = ({ }; }; -const getDiscourseNodes = (relations = getDiscourseRelations()) => { +const getDiscourseNodes = ( + relations?: ReturnType, + snapshot?: SettingsSnapshot, +) => { + const resolvedRelations = relations ?? getDiscourseRelations(snapshot); + const newStoreEnabled = snapshot + ? snapshot.featureFlags["Use new settings store"] + : isNewSettingsStoreEnabled(); const configuredNodes = ( - isNewSettingsStoreEnabled() + newStoreEnabled ? getAllDiscourseNodes() : Object.entries(discourseConfigRef.nodes).map( ([type, { text, children }]): DiscourseNode => { @@ -158,7 +166,7 @@ const getDiscourseNodes = (relations = getDiscourseRelations()) => { }, ) ).concat( - relations + resolvedRelations .filter((r) => r.triples.some((t) => t.some((n) => /anchor/i.test(n)))) .map((r) => ({ format: "", diff --git a/apps/roam/src/utils/getDiscourseRelationLabels.ts b/apps/roam/src/utils/getDiscourseRelationLabels.ts index 3355b8f22..33089618c 100644 --- a/apps/roam/src/utils/getDiscourseRelationLabels.ts +++ b/apps/roam/src/utils/getDiscourseRelationLabels.ts @@ -1,8 +1,17 @@ import getDiscourseRelations from "./getDiscourseRelations"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; -const getDiscourseRelationLabels = (relations = getDiscourseRelations()) => - Array.from(new Set(relations.flatMap((r) => [r.label, r.complement]))).filter( - (s) => !!s, - ); +const getDiscourseRelationLabels = ( + relations?: ReturnType, + snapshot?: SettingsSnapshot, +) => + Array.from( + new Set( + (relations ?? getDiscourseRelations(snapshot)).flatMap((r) => [ + r.label, + r.complement, + ]), + ), + ).filter((s) => !!s); export default getDiscourseRelationLabels; diff --git a/apps/roam/src/utils/getDiscourseRelations.ts b/apps/roam/src/utils/getDiscourseRelations.ts index c9f24a911..d7e36cab7 100644 --- a/apps/roam/src/utils/getDiscourseRelations.ts +++ b/apps/roam/src/utils/getDiscourseRelations.ts @@ -9,6 +9,7 @@ import DEFAULT_RELATION_VALUES from "~/data/defaultDiscourseRelations"; import { isNewSettingsStoreEnabled, getAllRelations, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import discourseConfigRef from "./discourseConfigRef"; @@ -35,9 +36,12 @@ export const getRelationsNode = (grammarNode = getGrammarNode()) => { return grammarNode?.children.find(matchNodeText("relations")); }; -const getDiscourseRelations = () => { - if (isNewSettingsStoreEnabled()) { - return getAllRelations(); +const getDiscourseRelations = (snapshot?: SettingsSnapshot) => { + const newStoreEnabled = snapshot + ? snapshot.featureFlags["Use new settings store"] + : isNewSettingsStoreEnabled(); + if (newStoreEnabled) { + return getAllRelations(snapshot); } const grammarNode = getGrammarNode(); diff --git a/apps/roam/src/utils/initializeDiscourseNodes.ts b/apps/roam/src/utils/initializeDiscourseNodes.ts index 46a832b48..847e8fdf7 100644 --- a/apps/roam/src/utils/initializeDiscourseNodes.ts +++ b/apps/roam/src/utils/initializeDiscourseNodes.ts @@ -2,9 +2,12 @@ import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTit import { createPage } from "roamjs-components/writes"; import INITIAL_NODE_VALUES from "~/data/defaultDiscourseNodes"; import getDiscourseNodes, { excludeDefaultNodes } from "./getDiscourseNodes"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; -const initializeDiscourseNodes = async () => { - const nodes = getDiscourseNodes().filter(excludeDefaultNodes); +const initializeDiscourseNodes = async (snapshot: SettingsSnapshot) => { + const nodes = getDiscourseNodes(undefined, snapshot).filter( + excludeDefaultNodes, + ); if (nodes.length === 0) { await Promise.all( INITIAL_NODE_VALUES.map( diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index c946134ce..27a095aba 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -25,7 +25,9 @@ import { onPageRefObserverChange, getSuggestiveOverlayHandler, } from "~/utils/pageRefObserverHandlers"; -import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { + type DiscourseNode, +} from "~/utils/getDiscourseNodes"; import { OnloadArgs } from "roamjs-components/types"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { render as renderGraphOverviewExport } from "~/components/ExportDiscourseContext"; @@ -52,9 +54,8 @@ import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTit import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import findDiscourseNode from "./findDiscourseNode"; import { - getPersonalSetting, - getFeatureFlag, - getGlobalSetting, + bulkReadSettings, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { onSettingChange, @@ -88,8 +89,10 @@ const getTitleAndUidFromHeader = (h1: HTMLHeadingElement) => { export const initObservers = ({ onloadArgs, + settings, }: { onloadArgs: OnloadArgs; + settings: SettingsSnapshot; }): { observers: MutationObserver[]; listeners: { @@ -107,11 +110,18 @@ export const initObservers = ({ callback: (e) => { const h1 = e as HTMLHeadingElement; const { title, uid } = getTitleAndUidFromHeader(h1); - const props = { title, h1, onloadArgs }; - const isSuggestiveModeEnabled = getFeatureFlag("Suggestive mode enabled"); + const settings = bulkReadSettings(); + + const props = { title, h1, onloadArgs }; - const node = findDiscourseNode({ uid, title }); + const isSuggestiveModeEnabled = + settings.featureFlags["Suggestive mode enabled"]; + const node = findDiscourseNode({ + uid, + title, + snapshot: settings, + }); const isDiscourseNode = node && node.backedBy !== "default"; if (isDiscourseNode) { renderDiscourseContext({ h1, uid }); @@ -125,10 +135,13 @@ export const initObservers = ({ renderCanvasReferences(linkedReferencesDiv, uid, onloadArgs); } } - - if (isQueryPage({ title })) renderQueryPage(props); - else if (isCurrentPageCanvas(props)) renderTldrawCanvas(props); - else if (isSidebarCanvas(props)) renderTldrawCanvasInSidebar(props); + if (isQueryPage({ title, snapshot: settings })) { + renderQueryPage(props); + } else if (isCurrentPageCanvas({ title, h1, snapshot: settings })) { + renderTldrawCanvas(props); + } else if (isSidebarCanvas({ title, h1, snapshot: settings })) { + renderTldrawCanvasInSidebar(props); + } }, }); @@ -137,6 +150,18 @@ export const initObservers = ({ render: (b) => renderQueryBlock(b, onloadArgs), }); + let batchedTagNodes: DiscourseNode[] | null = null; + const getNodesForTagBatch = (): DiscourseNode[] => { + if (batchedTagNodes === null) { + const settings = bulkReadSettings(); + batchedTagNodes = getDiscourseNodes(undefined, settings); + queueMicrotask(() => { + batchedTagNodes = null; + }); + } + return batchedTagNodes; + }; + const nodeTagPopupButtonObserver = createHTMLObserver({ className: "rm-page-ref--tag", tag: "SPAN", @@ -145,7 +170,7 @@ export const initObservers = ({ if (tag) { const normalizedTag = getCleanTagText(tag); - for (const node of getDiscourseNodes()) { + for (const node of getNodesForTagBatch()) { const normalizedNodeTag = node.tag ? getCleanTagText(node.tag) : ""; if (normalizedTag === normalizedNodeTag) { renderNodeTagPopupButton(s, node, onloadArgs.extensionAPI); @@ -203,7 +228,7 @@ export const initObservers = ({ const suggestiveHandler = getSuggestiveOverlayHandler(onloadArgs); const toggleSuggestiveOverlay = onPageRefObserverChange(suggestiveHandler); - if (getPersonalSetting([PERSONAL_KEYS.suggestiveModeOverlay])) { + if (settings.personalSettings[PERSONAL_KEYS.suggestiveModeOverlay]) { addPageRefObserver(suggestiveHandler); } @@ -233,34 +258,40 @@ export const initObservers = ({ }, }); - if (getPersonalSetting([PERSONAL_KEYS.pagePreview])) + if (settings.personalSettings[PERSONAL_KEYS.pagePreview]) addPageRefObserver(previewPageRefHandler); - if (getPersonalSetting([PERSONAL_KEYS.discourseContextOverlay])) { + + if (settings.personalSettings[PERSONAL_KEYS.discourseContextOverlay]) { const overlayHandler = getOverlayHandler(onloadArgs); onPageRefObserverChange(overlayHandler)(true); } + if (getPageRefObserversSize()) enablePageRefObserver(); const configPageUid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); const hashChangeListener = (e: Event) => { const evt = e as HashChangeEvent; + const settings = bulkReadSettings(); // Attempt to refresh config navigating away from config page // doesn't work if they update via sidebar if ( (configPageUid && evt.oldURL.endsWith(configPageUid)) || - getDiscourseNodes().some(({ type }) => evt.oldURL.endsWith(type)) + getDiscourseNodes(undefined, settings).some(({ type }) => + evt.oldURL.endsWith(type), + ) ) { - refreshConfigTree(); + refreshConfigTree(settings); } }; - let globalTrigger = ( - getGlobalSetting([GLOBAL_KEYS.trigger]) ?? "\\" - ).trim(); - const personalTriggerCombo = getPersonalSetting([ - PERSONAL_KEYS.personalNodeMenuTrigger, - ]); + let globalTrigger = settings.globalSettings[GLOBAL_KEYS.trigger].trim(); + const personalTriggerComboRaw = + settings.personalSettings[PERSONAL_KEYS.personalNodeMenuTrigger]; + const personalTriggerCombo = + typeof personalTriggerComboRaw === "object" + ? personalTriggerComboRaw + : undefined; let personalTrigger = personalTriggerCombo?.key; let personalModifiers = personalTriggerCombo ? getModifiersFromCombo(personalTriggerCombo) @@ -291,11 +322,17 @@ export const initObservers = ({ className: "starred-pages-wrapper", callback: (el) => { void (async () => { - const isLeftSidebarEnabled = getFeatureFlag("Enable left sidebar"); + const settings = bulkReadSettings(); + const isLeftSidebarEnabled = + settings.featureFlags["Enable left sidebar"]; const container = el as HTMLDivElement; if (isLeftSidebarEnabled) { container.style.padding = "0"; - await mountLeftSidebar(container, onloadArgs); + await mountLeftSidebar({ + wrapper: container, + onloadArgs, + initialSnapshot: settings, + }); } })(); }, @@ -343,7 +380,7 @@ export const initObservers = ({ }; let customTrigger = - getPersonalSetting([PERSONAL_KEYS.nodeSearchMenuTrigger]) ?? "@"; + settings.personalSettings[PERSONAL_KEYS.nodeSearchMenuTrigger]; const unsubSearchTrigger = onSettingChange( settingKeys.nodeSearchMenuTrigger, @@ -403,10 +440,8 @@ export const initObservers = ({ }; const nodeCreationPopoverListener = debounce(() => { - const isTextSelectionPopupEnabled = - getPersonalSetting([PERSONAL_KEYS.textSelectionPopup]) !== false; - - if (!isTextSelectionPopupEnabled) return; + const settings = bulkReadSettings(); + if (!settings.personalSettings[PERSONAL_KEYS.textSelectionPopup]) return; const selection = window.getSelection(); diff --git a/apps/roam/src/utils/isCanvasPage.ts b/apps/roam/src/utils/isCanvasPage.ts index a9a3b7a49..4c7d97331 100644 --- a/apps/roam/src/utils/isCanvasPage.ts +++ b/apps/roam/src/utils/isCanvasPage.ts @@ -1,10 +1,21 @@ import { DEFAULT_CANVAS_PAGE_FORMAT } from ".."; -import { getGlobalSetting } from "~/components/settings/utils/accessors"; +import { + getGlobalSetting, + type SettingsSnapshot, +} from "~/components/settings/utils/accessors"; import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys"; -export const isCanvasPage = ({ title }: { title: string }) => { +export const isCanvasPage = ({ + title, + snapshot, +}: { + title: string; + snapshot?: SettingsSnapshot; +}) => { const format = - getGlobalSetting([GLOBAL_KEYS.canvasPageFormat]) || + (snapshot + ? snapshot.globalSettings[GLOBAL_KEYS.canvasPageFormat] + : getGlobalSetting([GLOBAL_KEYS.canvasPageFormat])) || DEFAULT_CANVAS_PAGE_FORMAT; const canvasRegex = new RegExp(`^${format}$`.replace(/\*/g, ".+")); return canvasRegex.test(title); @@ -13,19 +24,25 @@ export const isCanvasPage = ({ title }: { title: string }) => { export const isCurrentPageCanvas = ({ title, h1, + snapshot, }: { title: string; h1: HTMLHeadingElement; + snapshot: SettingsSnapshot; }) => { - return isCanvasPage({ title }) && !!h1.closest(".roam-article"); + return isCanvasPage({ title, snapshot }) && !!h1.closest(".roam-article"); }; export const isSidebarCanvas = ({ title, h1, + snapshot, }: { title: string; h1: HTMLHeadingElement; + snapshot: SettingsSnapshot; }) => { - return isCanvasPage({ title }) && !!h1.closest(".rm-sidebar-outline"); + return ( + isCanvasPage({ title, snapshot }) && !!h1.closest(".rm-sidebar-outline") + ); }; diff --git a/apps/roam/src/utils/isQueryPage.ts b/apps/roam/src/utils/isQueryPage.ts index aa3df2255..4e6109944 100644 --- a/apps/roam/src/utils/isQueryPage.ts +++ b/apps/roam/src/utils/isQueryPage.ts @@ -1,7 +1,14 @@ import { getQueryPages } from "~/components/settings/QueryPagesPanel"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; -export const isQueryPage = ({ title }: { title: string }): boolean => { - const queryPages = getQueryPages(); +export const isQueryPage = ({ + title, + snapshot, +}: { + title: string; + snapshot: SettingsSnapshot; +}): boolean => { + const queryPages = getQueryPages(snapshot); const matchesQueryPage = queryPages.some((queryPage) => { const escapedPattern = queryPage diff --git a/apps/roam/src/utils/posthog.ts b/apps/roam/src/utils/posthog.ts index 407c8676d..c2b4bc8a5 100644 --- a/apps/roam/src/utils/posthog.ts +++ b/apps/roam/src/utils/posthog.ts @@ -2,13 +2,13 @@ import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid"; import { getVersionWithDate } from "./getVersion"; import posthog from "posthog-js"; import type { CaptureResult } from "posthog-js"; -import { getPersonalSetting } from "~/components/settings/utils/accessors"; -import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; let initialized = false; -const doInitPostHog = (): void => { +export const initPostHog = (): void => { if (initialized) return; + if (window.roamAlphaAPI.graph.isEncrypted) return; + if (window.roamAlphaAPI.graph.type === "offline") return; const propertyDenylist = new Set([ "$ip", "$device_id", @@ -58,19 +58,10 @@ const doInitPostHog = (): void => { }; export const enablePostHog = (): void => { - doInitPostHog(); + initPostHog(); posthog.opt_in_capturing(); }; export const disablePostHog = (): void => { if (initialized) posthog.opt_out_capturing(); }; - -export const initPostHog = (): void => { - const disabled = getPersonalSetting([ - PERSONAL_KEYS.disableProductDiagnostics, - ]); - if (!disabled) { - doInitPostHog(); - } -}; diff --git a/apps/roam/src/utils/refreshConfigTree.ts b/apps/roam/src/utils/refreshConfigTree.ts index 606d8097c..8d23401c8 100644 --- a/apps/roam/src/utils/refreshConfigTree.ts +++ b/apps/roam/src/utils/refreshConfigTree.ts @@ -6,6 +6,7 @@ import registerDiscourseDatalogTranslators from "./registerDiscourseDatalogTrans import { unregisterDatalogTranslator } from "./conditionToDatalog"; import type { PullBlock } from "roamjs-components/types/native"; import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/data/constants"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; const getPagesStartingWithPrefix = (prefix: string) => ( @@ -17,8 +18,8 @@ const getPagesStartingWithPrefix = (prefix: string) => uid: r[0][":block/uid"] || "", })); -const refreshConfigTree = () => { - getDiscourseRelationLabels().forEach((key) => +const refreshConfigTree = (snapshot?: SettingsSnapshot) => { + getDiscourseRelationLabels(undefined, snapshot).forEach((key) => unregisterDatalogTranslator({ key }), ); discourseConfigRef.tree = getBasicTreeByParentUid( @@ -36,7 +37,7 @@ const refreshConfigTree = () => { ]; }), ); - registerDiscourseDatalogTranslators(); + registerDiscourseDatalogTranslators(snapshot); }; export default refreshConfigTree; diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index 091ec978d..b9bd53719 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -21,6 +21,7 @@ import { fireQuerySync, getWhereClauses } from "./fireQuery"; import { toVar } from "./compileDatalog"; import { getExistingRelationPageUid } from "./createReifiedBlock"; import { getStoredRelationsEnabled } from "./storedRelations"; +import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; const hasTag = (node: DiscourseNode): node is DiscourseNode & { tag: string } => !!node.tag; @@ -87,9 +88,9 @@ const collectVariables = (clauses: DatalogClause[]): Set => const ANY_DISCOURSE_NODE = "Any discourse node"; -const registerDiscourseDatalogTranslators = () => { - const discourseRelations = getDiscourseRelations(); - const discourseNodes = getDiscourseNodes(discourseRelations); +const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { + const discourseRelations = getDiscourseRelations(snapshot); + const discourseNodes = getDiscourseNodes(discourseRelations, snapshot); const isACallback: Parameters< typeof registerDatalogTranslator diff --git a/apps/roam/src/utils/setQueryPages.ts b/apps/roam/src/utils/setQueryPages.ts index cf5a8be13..f75fb521b 100644 --- a/apps/roam/src/utils/setQueryPages.ts +++ b/apps/roam/src/utils/setQueryPages.ts @@ -1,25 +1,19 @@ import { OnloadArgs } from "roamjs-components/types"; import { - getPersonalSetting, setPersonalSetting, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS, QUERY_KEYS, } from "~/components/settings/utils/settingKeys"; -export const setInitialQueryPages = (onloadArgs: OnloadArgs) => { - // Legacy extensionAPI stored query-pages as string | string[] | Record. - // Coerce to string[] for backward compatibility with old stored formats. - const raw = getPersonalSetting>([ - PERSONAL_KEYS.query, - QUERY_KEYS.queryPages, - ]); - const queryPageArray = Array.isArray(raw) - ? raw - : typeof raw === "string" && raw - ? [raw] - : []; +export const setInitialQueryPages = ( + onloadArgs: OnloadArgs, + snapshot: SettingsSnapshot, +) => { + const queryPageArray = + snapshot.personalSettings[PERSONAL_KEYS.query][QUERY_KEYS.queryPages]; if (!queryPageArray.includes("discourse-graph/queries/*")) { const updated = [...queryPageArray, "discourse-graph/queries/*"]; void onloadArgs.extensionAPI.settings.set("query-pages", updated); From 25cc4060e1c156b66511336c305c429fef189299 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Wed, 15 Apr 2026 22:34:40 +0530 Subject: [PATCH 7/7] ENG-1617: se existing 'getConfigTree equivalent functions' for specific setting groups (#946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (343dc117) 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 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` 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 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 --- .../roam/src/components/DiscourseNodeMenu.tsx | 18 +-- .../components/DiscourseNodeSearchMenu.tsx | 9 +- .../src/components/settings/AdminPanel.tsx | 27 ++-- .../components/settings/DefaultFilters.tsx | 23 ++-- .../settings/DiscourseRelationConfigPanel.tsx | 44 +++--- .../components/settings/ExportSettings.tsx | 15 +- .../components/settings/GeneralSettings.tsx | 15 +- .../settings/HomePersonalSettings.tsx | 39 +++++- .../settings/KeyboardShortcutInput.tsx | 15 +- .../settings/LeftSidebarGlobalSettings.tsx | 24 ++-- .../settings/LeftSidebarPersonalSettings.tsx | 24 ++-- .../src/components/settings/NodeConfig.tsx | 10 +- .../src/components/settings/QuerySettings.tsx | 12 +- .../roam/src/components/settings/Settings.tsx | 59 ++++++-- .../settings/SuggestiveModeSettings.tsx | 25 +++- .../components/BlockPropSettingPanels.tsx | 104 ++++---------- .../components/settings/utils/accessors.ts | 128 +++--------------- 17 files changed, 282 insertions(+), 309 deletions(-) diff --git a/apps/roam/src/components/DiscourseNodeMenu.tsx b/apps/roam/src/components/DiscourseNodeMenu.tsx index c7f2e2b96..e25ca7e61 100644 --- a/apps/roam/src/components/DiscourseNodeMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeMenu.tsx @@ -27,11 +27,9 @@ import { getNewDiscourseNodeText } from "~/utils/formatUtils"; import { OnloadArgs } from "roamjs-components/types"; import { formatHexColor } from "./settings/DiscourseNodeCanvasSettings"; import posthog from "posthog-js"; -import { - getPersonalSetting, - setPersonalSetting, -} from "~/components/settings/utils/accessors"; +import { setPersonalSetting } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; +import type { PersonalSettings } from "~/components/settings/utils/zodSchema"; type Props = { textarea?: HTMLTextAreaElement; @@ -420,19 +418,15 @@ export const comboToString = (combo: IKeyCombo): string => { export const NodeMenuTriggerComponent = ({ extensionAPI, + initialValue, }: { extensionAPI: OnloadArgs["extensionAPI"]; + initialValue: PersonalSettings["Personal node menu trigger"]; }) => { const inputRef = useRef(null); const [isActive, setIsActive] = useState(false); - const [comboKey, setComboKey] = useState( - () => - getPersonalSetting([ - PERSONAL_KEYS.personalNodeMenuTrigger, - ]) || { - modifiers: 0, - key: "", - }, + const [comboKey, setComboKey] = useState(() => + typeof initialValue === "object" ? initialValue : { modifiers: 0, key: "" }, ); const handleKeyDown = useCallback( diff --git a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx index 03fdfc82d..8058d869e 100644 --- a/apps/roam/src/components/DiscourseNodeSearchMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeSearchMenu.tsx @@ -25,10 +25,7 @@ import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression"; import { Result } from "~/utils/types"; import MiniSearch from "minisearch"; -import { - getPersonalSetting, - setPersonalSetting, -} from "~/components/settings/utils/accessors"; +import { setPersonalSetting } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; type Props = { @@ -709,12 +706,14 @@ export const renderDiscourseNodeSearchMenu = (props: Props) => { export const NodeSearchMenuTriggerSetting = ({ onloadArgs, + initialValue, }: { onloadArgs: OnloadArgs; + initialValue: string; }) => { const extensionAPI = onloadArgs.extensionAPI; const [nodeSearchTrigger, setNodeSearchTrigger] = useState( - getPersonalSetting([PERSONAL_KEYS.nodeSearchMenuTrigger]) ?? "@", + () => initialValue, ); const handleNodeSearchTriggerChange = ( diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index ae9e0d7d7..aeb7f5b47 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -15,8 +15,8 @@ import { import Description from "roamjs-components/components/Description"; import { Select } from "@blueprintjs/select"; import { - getFeatureFlag, setFeatureFlag, + type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { onSettingChange, @@ -264,7 +264,11 @@ const NodeListTab = (): React.ReactElement => { ); }; -const FeatureFlagsTab = (): React.ReactElement => { +const FeatureFlagsTab = ({ + featureFlags, +}: { + featureFlags: SettingsSnapshot["featureFlags"]; +}): React.ReactElement => { const legacySuggestiveModeMeta = useMemo(() => { refreshConfigTree(); return { @@ -276,8 +280,8 @@ const FeatureFlagsTab = (): React.ReactElement => { }; }, []); - const [suggestiveModeEnabled, setSuggestiveModeEnabled] = useState(() => - getFeatureFlag("Suggestive mode enabled"), + const [suggestiveModeEnabled, setSuggestiveModeEnabled] = useState( + featureFlags["Suggestive mode enabled"], ); const [suggestiveModeUid, setSuggestiveModeUid] = useState( legacySuggestiveModeMeta.suggestiveModeEnabledUid, @@ -365,6 +369,7 @@ const FeatureFlagsTab = (): React.ReactElement => { title="Use new settings store" description="When enabled, accessor getters read from block props instead of the old system. Surfaces dual-write gaps during development." featureKey="Use new settings store" + initialValue={featureFlags["Use new settings store"]} />