diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index e543966c3..b1b3ae4fa 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -8,9 +8,10 @@ import { Tooltip, } from "@blueprintjs/core"; import React, { useState } from "react"; -import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { + excludeDefaultNodes, +} from "~/utils/getDiscourseNodes"; import refreshConfigTree from "~/utils/refreshConfigTree"; -import type { CustomField } from "roamjs-components/components/ConfigPanels/types"; import posthog from "posthog-js"; import getDiscourseRelations, { type DiscourseRelation, @@ -24,20 +25,12 @@ import { } from "./utils/accessors"; import { GLOBAL_KEYS } from "./utils/settingKeys"; import { invalidateDiscourseNodeTypeCaches } from "~/utils/discourseNodeTypeCache"; +import { useSettingsNav } from "./navigation/SettingsNavContext"; -type DiscourseNodeConfigPanelProps = React.ComponentProps< - CustomField["options"]["component"] -> & { - isPopup?: boolean; - setSelectedTabId: (id: string) => void; -}; - -const DiscourseNodeConfigPanel: React.FC = ({ - isPopup, - setSelectedTabId, -}) => { +const DiscourseNodeConfigPanel: React.FC = () => { + const { push } = useSettingsNav(); const [nodes, setNodes] = useState(() => - getDiscourseNodes().filter((n) => n.backedBy === "user"), + getDiscourseNodes().filter(excludeDefaultNodes), ); const [label, setLabel] = useState(""); const [isCreating, setIsCreating] = useState(false); @@ -52,11 +45,8 @@ const DiscourseNodeConfigPanel: React.FC = ({ >([]); const [nodeTypeIdToDelete, setNodeTypeIdToDelete] = useState(""); const navigateToNode = (uid: string) => { - if (isPopup) { - setSelectedTabId(uid); - } else { - window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - } + push(uid); + posthog.capture("Settings: Node Type Opened", { nodeTypeUid: uid }); }; const createNodeType = async (): Promise => { diff --git a/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx b/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx index ea32cd0e9..e387f6ec3 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx @@ -155,7 +155,8 @@ const NodeSpecification = ({ -
- -
+ {enabled && ( +
+ +
+ )} ); }; diff --git a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx index 67a546c92..f08566c8c 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from "react"; -import { Button, Intent } from "@blueprintjs/core"; +import { Button, Collapse, Intent } from "@blueprintjs/core"; import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; import getSubTree from "roamjs-components/util/getSubTree"; import { DiscourseNode } from "~/utils/getDiscourseNodes"; @@ -17,6 +17,7 @@ import { TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import { RenderRoamBlock } from "~/utils/roamReactComponents"; +import Description from "~/components/settings/SettingsDescription"; import { ROAM_DOCS, withDocsLink } from "./utils/docs"; const DiscourseNodeSuggestiveRules = ({ @@ -42,6 +43,7 @@ const DiscourseNodeSuggestiveRules = ({ }).uid || "", [nodeUid], ); + const [isTemplateOpen, setIsTemplateOpen] = useState(false); const [isUpdating, setIsUpdating] = useState(false); @@ -68,18 +70,41 @@ const DiscourseNodeSuggestiveRules = ({ }; return ( -
- +
+
+
+
+ {/* Collapse unmounts its children, so the editor's ephemeral buffer block is only + created while the template is actually open. */} + + {/* The toggle above is this setting's header, so the panel omits its own. */} +
+ +
+
+
= { + [nodeConfigSegmentIds.index]: "Index", + [nodeConfigSegmentIds.template]: "Template", +}; + +const GrammarNodesRoute = ({ + onloadArgs, +}: { + onloadArgs: OnloadArgs; +}): JSX.Element => { + const { segments, goToDepth } = useSettingsNav(); + const nodes = getDiscourseNodes().filter(excludeDefaultNodes); + + const [nodeTypeUid, subPage] = segments; + const node = nodeTypeUid + ? nodes.find((n) => n.type === nodeTypeUid) + : undefined; + + // A deleted node type or stale deep link resolves to nothing; return to the list. + const isStalePath = Boolean(nodeTypeUid) && !node; + useEffect(() => { + if (isStalePath) goToDepth(0); + }, [isStalePath, goToDepth]); + + const resolveLabel = (segment: string, segmentIndex: number): string => + segmentIndex === 0 + ? (nodes.find((n) => n.type === segment)?.text ?? segment) + : (SUB_PAGE_LABELS[segment] ?? segment); + + // Sub-pages fall through to the stylesheet's default dot colour. + const dotColor = subPage + ? undefined + : formatHexColor(node?.canvasSettings?.color ?? "") || undefined; + + return ( +
+ +
+ {!node ? ( +
+ +
+ ) : subPage === nodeConfigSegmentIds.index ? ( + + ) : subPage === nodeConfigSegmentIds.template ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default GrammarNodesRoute; diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 92e68d5dd..1bef47313 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -1,13 +1,9 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; -import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; import { getSubTree } from "roamjs-components/util"; import Description from "~/components/settings/SettingsDescription"; import { Label, - Tabs, - Tab, - TabId, InputGroup, ControlGroup, Tooltip, @@ -18,8 +14,6 @@ import DiscourseNodeAttributes from "./DiscourseNodeAttributes"; import DiscourseNodeCanvasSettings, { formatHexColor, } from "./DiscourseNodeCanvasSettings"; -import DiscourseNodeIndex from "./DiscourseNodeIndex"; -import { OnloadArgs } from "roamjs-components/types"; import setInputSetting from "roamjs-components/util/setInputSetting"; import { getDiscourseNodeSetting, @@ -30,7 +24,6 @@ import { CANVAS_KEYS, DISCOURSE_NODE_KEYS, SPECIFICATION_KEYS, - TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import DiscourseNodeSuggestiveRules from "./DiscourseNodeSuggestiveRules"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; @@ -40,6 +33,10 @@ import { DiscourseNodeSelectPanel, } from "./components/BlockPropSettingPanels"; import { ROAM_DOCS, withDocsLink } from "./utils/docs"; +import { SettingsGroup } from "./components/SettingsHeadings"; +import SettingsDrillDownRow from "./components/SettingsDrillDownRow"; +import { useSettingsNav } from "./navigation/SettingsNavContext"; +import { nodeConfigSegmentIds } from "./utils/settingsNavigation"; export const getCleanTagText = (tag: string): string => { return tag.replace(/^#+/, "").trim().toUpperCase(); @@ -81,11 +78,18 @@ const DiscourseNodeColorSetting = ({ [canvasUid, nodeType], ); + // Navigating away unmounts mid-debounce, so the pending colour is written rather than dropped. + const pendingColorRef = useRef(null); + const persistColorValueRef = useRef(persistColorValue); + persistColorValueRef.current = persistColorValue; useEffect(() => { return () => { if (!colorWriteTimeoutRef.current) return; window.clearTimeout(colorWriteTimeoutRef.current); + const pending = pendingColorRef.current; + pendingColorRef.current = null; + if (pending !== null) persistColorValueRef.current(pending); }; }, []); @@ -94,8 +98,10 @@ const DiscourseNodeColorSetting = ({ window.clearTimeout(colorWriteTimeoutRef.current); colorWriteTimeoutRef.current = null; } + pendingColorRef.current = colorValue; colorWriteTimeoutRef.current = window.setTimeout(() => { persistColorValue(colorValue); + pendingColorRef.current = null; colorWriteTimeoutRef.current = null; }, COLOR_WRITE_DEBOUNCE_MS); }; @@ -134,6 +140,7 @@ const DiscourseNodeColorSetting = ({ window.clearTimeout(colorWriteTimeoutRef.current); colorWriteTimeoutRef.current = null; } + pendingColorRef.current = null; setColor(""); persistColorValue(""); }} @@ -158,13 +165,7 @@ const generateTagPlaceholder = (node: DiscourseNode): string => { return `#${nodeTextPrefix}-candidate`; // Evidence = #evi-candidate }; -const NodeConfig = ({ - node, - onloadArgs, -}: { - node: DiscourseNode; - onloadArgs: OnloadArgs; -}) => { +const NodeConfig = ({ node }: { node: DiscourseNode }) => { const getUid = (key: string) => getSubTree({ parentUid: node.type, @@ -174,19 +175,17 @@ const NodeConfig = ({ const descriptionUid = getUid("Description"); const shortcutUid = getUid("Shortcut"); const tagUid = getUid("Tag"); - const templateUid = getUid("Template"); const overlayUid = getUid("Overlay"); const canvasUid = getUid("Canvas"); const graphOverviewUid = getUid("Graph Overview"); const specificationUid = getUid("Specification"); - const indexUid = getUid("Index"); const suggestiveRulesUid = getUid("Suggestive Rules"); const attributeNode = getSubTree({ parentUid: node.type, key: "Attributes", }); - const [selectedTabId, setSelectedTabId] = useState("general"); + const nav = useSettingsNav(); const [tagError, setTagError] = useState(""); const [formatError, setFormatError] = useState(""); const [shortcutError, setShortcutError] = useState(""); @@ -292,220 +291,171 @@ const NodeConfig = ({ ); return ( - <> - setSelectedTabId(id)} - selectedTabId={selectedTabId} - renderActiveTabPanelOnly={true} - > - - - -
- -
- -
- } - /> - + + - -
- } + description={`The saved list of all ${node.text} pages \u2014 which pages appear and which columns show.`} + buttonText={`See all ${node.text} nodes`} + onClick={() => nav.push(nodeConfigSegmentIds.index)} + /> + + + - - - - - } + description={withDocsLink( + `The format ${node.text} pages should have.`, + ROAM_DOCS.grammarNodes, + )} + settingKeys={[DISCOURSE_NODE_KEYS.format]} + initialValue={node.format} + error={formatError} + onChange={setFormatValue} + order={3} + parentUid={node.type} + uid={formatUid} + /> + + + + - - - - } + description={withDocsLink( + `The template that auto fills ${node.text} page when generated.`, + ROAM_DOCS.creatingNodes, + )} + buttonText="Edit template" + onClick={() => nav.push(nodeConfigSegmentIds.template)} /> - - >( - node.type, - [DISCOURSE_NODE_KEYS.attributes], - )} - /> - c.text)} - initialValue={ - getDiscourseNodeSetting(node.type, [ - DISCOURSE_NODE_KEYS.overlay, - ]) ?? "" - } - order={0} - parentUid={node.type} - uid={overlayUid} - /> - - } + + + + + + + + {/* Settings mid-migration live here until they either replace their + predecessor or are removed. */} + + + + + {isSyncEnabled() && ( + + + + )} + + + >( + node.type, + [DISCOURSE_NODE_KEYS.attributes], + )} /> - - - - + c.text)} + initialValue={ + getDiscourseNodeSetting(node.type, [ + DISCOURSE_NODE_KEYS.overlay, + ]) ?? "" } + order={0} + parentUid={node.type} + uid={overlayUid} /> - {isSyncEnabled() && ( - - - - } - /> - )} - - + + ); }; diff --git a/apps/roam/src/components/settings/NodeIndexPage.tsx b/apps/roam/src/components/settings/NodeIndexPage.tsx new file mode 100644 index 000000000..c39558a82 --- /dev/null +++ b/apps/roam/src/components/settings/NodeIndexPage.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { getSubTree } from "roamjs-components/util"; +import { OnloadArgs } from "roamjs-components/types"; +import { DiscourseNode } from "~/utils/getDiscourseNodes"; +import DiscourseNodeIndex from "./DiscourseNodeIndex"; + +const NodeIndexPage = ({ + node, + onloadArgs, +}: { + node: DiscourseNode; + onloadArgs: OnloadArgs; +}): JSX.Element => { + const indexUid = getSubTree({ parentUid: node.type, key: "Index" }).uid; + return ( +
+ +
+ ); +}; + +export default NodeIndexPage; diff --git a/apps/roam/src/components/settings/NodeTemplatePage.tsx b/apps/roam/src/components/settings/NodeTemplatePage.tsx new file mode 100644 index 000000000..94947976d --- /dev/null +++ b/apps/roam/src/components/settings/NodeTemplatePage.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { getSubTree } from "roamjs-components/util"; +import { DiscourseNode } from "~/utils/getDiscourseNodes"; +import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; +import { TEMPLATE_SETTING_KEYS } from "~/components/settings/utils/settingKeys"; +import { ROAM_DOCS, withDocsLink } from "./utils/docs"; + +const NodeTemplatePage = ({ node }: { node: DiscourseNode }): JSX.Element => { + const templateUid = getSubTree({ parentUid: node.type, key: "Template" }).uid; + return ( +
+ +
+ ); +}; + +export default NodeTemplatePage; diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index face61ec4..ea3e1a6d4 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -1,4 +1,11 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; import { OnloadArgs } from "roamjs-components/types"; import { Classes, @@ -16,11 +23,6 @@ import discourseConfigRef from "~/utils/discourseConfigRef"; import DiscourseGraphExport from "./ExportSettings"; import QuerySettings from "./QuerySettings"; import AdminPanel from "./AdminPanel"; -import DiscourseNodeConfigPanel from "./DiscourseNodeConfigPanel"; -import getDiscourseNodes, { - excludeDefaultNodes, -} from "~/utils/getDiscourseNodes"; -import NodeConfig from "./NodeConfig"; import PreferencesGeneral from "./PreferencesGeneral"; import PreferencesStyling from "./PreferencesStyling"; import LeftSidebarSettings from "./LeftSidebarSettings"; @@ -32,7 +34,14 @@ import { getVersionWithDate } from "~/utils/getVersion"; import posthog from "posthog-js"; import { bulkReadSettings } from "./utils/accessors"; import { onSettingChange, settingKeys } from "./utils/settingsEmitter"; -import { SETTINGS_TAB_IDS, resolveSettingsTabId } from "./utils/settingsTabs"; +import { SETTINGS_TAB_IDS } from "./utils/settingsTabs"; +import { + resolveInitialSettingsPath, + settingsNavReducer, + tabIdOf, +} from "./utils/settingsNavigation"; +import { SettingsNavProvider } from "./navigation/SettingsNavContext"; +import GrammarNodesRoute from "./GrammarNodesRoute"; const SectionHeader = ({ children }: { children: React.ReactNode }) => (
@@ -77,10 +86,15 @@ export const SettingsDialog = ({ const relationsNode = grammarNode?.children.find( (node) => node.text === "relations", ); - const nodesNode = grammarNode?.children.find((node) => node.text === "nodes"); - const nodes = getDiscourseNodes().filter(excludeDefaultNodes); - const [activeTabId, setActiveTabId] = useState(() => - resolveSettingsTabId(selectedTabId), + const [path, dispatch] = useReducer( + settingsNavReducer, + selectedTabId, + resolveInitialSettingsPath, + ); + const activeTabId = tabIdOf(path); + const selectTab = useCallback( + (tabId: string) => dispatch({ type: "select-tab", tabId }), + [], ); // eslint-disable-next-line react-hooks/exhaustive-deps const settings = useMemo(() => bulkReadSettings(), [activeTabId]); @@ -98,15 +112,14 @@ export const SettingsDialog = ({ const { versionStamp } = getVersionWithDate(); const openAdminPanel = (): void => { setShowAdminPanel(true); - setActiveTabId(SETTINGS_TAB_IDS.admin); + selectTab(SETTINGS_TAB_IDS.admin); posthog.capture("Settings: Admin Panel Opened from Footer"); }; + const initialTabId = useRef(activeTabId).current; useEffect(() => { - posthog.capture("Settings: Dialog Opened", { - initialTabId: String(resolveSettingsTabId(selectedTabId)), - }); - }, [selectedTabId]); + posthog.capture("Settings: Dialog Opened", { initialTabId }); + }, [initialTabId]); useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { @@ -114,14 +127,14 @@ export const SettingsDialog = ({ e.stopPropagation(); e.preventDefault(); setShowAdminPanel(true); - setActiveTabId(SETTINGS_TAB_IDS.admin); + selectTab(SETTINGS_TAB_IDS.admin); posthog.capture("Settings: Admin Panel Opened via Shortcut"); } }; window.addEventListener("keydown", handleKeyPress); return () => window.removeEventListener("keydown", handleKeyPress); - }, []); + }, [selectTab]); return ( { - setActiveTabId(id); + selectTab(String(id)); posthog.capture("Settings: Tab Opened", { tabId: String(id), }); @@ -238,16 +251,10 @@ export const SettingsDialog = ({ + + + } /> } /> - {/* Per-node tabs stay in the rail until ENG-2186 adds the drill-down. */} - Node types - {nodes.map((n) => ( - } - /> - ))} Advanced void, delayMs: number) => void; +}; + +// Keeps the timer and the registry entry for a panel in step: scheduling a new +// value replaces any pending one, and committing (by timer, by flush, or by +// unmount) runs the write exactly once. Unmount commits rather than cancels -- +// navigating away or closing a dialog straight after an edit used to discard it. +const useDeferredWrite = (): DeferredWrite => { + const timeoutRef = useRef(0); + const commitRef = useRef<(() => void) | null>(null); + + const forget = useCallback(() => { + window.clearTimeout(timeoutRef.current); + if (commitRef.current) { + removePendingSettingWrite(commitRef.current); + commitRef.current = null; + } + }, []); + + const schedule = useCallback( + (commit: () => void, delayMs: number) => { + forget(); + const runOnce = () => { + forget(); + commit(); + }; + commitRef.current = runOnce; + addPendingSettingWrite(runOnce); + timeoutRef.current = window.setTimeout(runOnce, delayMs); + }, + [forget], + ); + + useEffect(() => () => commitRef.current?.(), []); + + return { schedule }; +}; const BaseTextPanel = ({ title, @@ -138,7 +183,7 @@ const BaseTextPanel = ({ const [value, setValue] = useState(() => initialValue ?? ""); const errorRef = useRef(error); errorRef.current = error; - const debounceRef = useRef(0); + const { schedule } = useDeferredWrite(); const hasBlockSync = parentUid !== undefined && order !== undefined; const { onChange: rawSyncToBlock } = useSingleChildValue({ title: blockKey ?? title, @@ -151,10 +196,6 @@ const BaseTextPanel = ({ }); const syncToBlock = hasBlockSync ? rawSyncToBlock : undefined; - useEffect(() => { - return () => window.clearTimeout(debounceRef.current); - }, []); - const handleChange = ( e: ChangeEvent, ) => { @@ -162,15 +203,13 @@ const BaseTextPanel = ({ setValue(newValue); onChange?.(newValue); - window.clearTimeout(debounceRef.current); - debounceRef.current = window.setTimeout(() => { + schedule(() => { if (errorRef.current) return; syncToBlock?.(newValue); - debounceRef.current = window.setTimeout(() => { - if (errorRef.current) return; - refreshConfigTree(); - setter(settingKeys, newValue); - }, 100); + setter(settingKeys, newValue); + // Kept off the committed write so the block-prop value, which is what + // readers use, is never held back waiting on the tree re-read. + window.setTimeout(refreshConfigTree, REFRESH_DELAY_MS); }, DEBOUNCE_MS); }; @@ -297,22 +336,17 @@ const BaseNumberPanel = ({ toStr: (v: number) => `${v}`, }); const syncToBlock = hasBlockSync ? rawSyncToBlock : undefined; - const refreshTimeoutRef = useRef(0); - - useEffect(() => { - return () => window.clearTimeout(refreshTimeoutRef.current); - }, []); + const { schedule } = useDeferredWrite(); const handleChange = (valueAsNumber: number) => { if (Number.isNaN(valueAsNumber)) return; setValue(valueAsNumber); syncToBlock?.(valueAsNumber); - window.clearTimeout(refreshTimeoutRef.current); - refreshTimeoutRef.current = window.setTimeout(() => { - refreshConfigTree(); + schedule(() => { setter(settingKeys, valueAsNumber); + refreshConfigTree(); onChange?.(valueAsNumber); - }, 100); + }, REFRESH_DELAY_MS); }; return ( @@ -353,21 +387,16 @@ const BaseSelectPanel = ({ toStr: (s: string) => s, }); const syncToBlock = hasBlockSync ? rawSyncToBlock : undefined; - const refreshTimeoutRef = useRef(0); - - useEffect(() => { - return () => window.clearTimeout(refreshTimeoutRef.current); - }, []); + const { schedule } = useDeferredWrite(); const handleChange = (e: ChangeEvent) => { const newValue = e.target.value; setValue(newValue); syncToBlock?.(newValue); - window.clearTimeout(refreshTimeoutRef.current); - refreshTimeoutRef.current = window.setTimeout(() => { - refreshConfigTree(); + schedule(() => { setter(settingKeys, newValue); - }, 100); + refreshConfigTree(); + }, REFRESH_DELAY_MS); }; return ( diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 3e0fd8aa6..e077d105b 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -16,7 +16,9 @@ import type { DiscourseNodeBaseProps } from "./BlockPropSettingPanels"; const DEBOUNCE_MS = 250; const TEMPLATE_BUFFER_TEXT = "Template"; -type DualWriteBlocksPanelProps = DiscourseNodeBaseProps & { +type DualWriteBlocksPanelProps = Omit & { + /** Omit when the caller already renders a header for this setting. */ + title?: string; uid: string; defaultValue?: InputTextNode[]; }; @@ -115,7 +117,7 @@ const DualWriteBlocksPanel = ({ const newUid = window.roamAlphaAPI.util.generateUID(); const dv = defaultValueRef.current; const seed: InputTextNode[] = dv && dv.length > 0 ? dv : [{ text: " " }]; - void createBlock({ + const created = createBlock({ node: { text: TEMPLATE_BUFFER_TEXT, uid: newUid, children: seed }, parentUid: nodeType, order: "last", @@ -125,23 +127,44 @@ const DualWriteBlocksPanel = ({ return () => { cancelled = true; setBufferUid(null); - void deleteBlock(newUid); + // Deleting a uid whose createBlock is still in flight orphans the buffer block. + void created.then( + () => deleteBlock(newUid), + () => undefined, + ); }; }, [isNewStore, nodeType]); + const writeChanges = useCallback(() => { + if (!renderUid) return; + const tree = getFullTreeByParentUid(renderUid); + const serialized = serializeBlockTree(tree.children); + setDiscourseNodeSetting(nodeType, settingKeys, serialized); + if (isNewStore && renderUid !== uid) { + const legacyTree = getFullTreeByParentUid(uid); + mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); + } + }, [renderUid, uid, isNewStore, nodeType, settingKeys]); + + // In a ref so unmount cleanup can flush without re-running on every identity change. + const writeChangesRef = useRef(writeChanges); + writeChangesRef.current = writeChanges; + + const flushPendingChanges = useCallback(() => { + if (!debounceRef.current) return; + window.clearTimeout(debounceRef.current); + debounceRef.current = 0; + writeChangesRef.current(); + }, []); + const handleChange = useCallback(() => { if (!renderUid) return; window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { - const tree = getFullTreeByParentUid(renderUid); - const serialized = serializeBlockTree(tree.children); - setDiscourseNodeSetting(nodeType, settingKeys, serialized); - if (isNewStore && renderUid !== uid) { - const legacyTree = getFullTreeByParentUid(uid); - mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); - } + debounceRef.current = 0; + writeChangesRef.current(); }, DEBOUNCE_MS); - }, [renderUid, uid, isNewStore, nodeType, settingKeys]); + }, [renderUid]); useEffect(() => { const el = containerRef.current; @@ -181,20 +204,23 @@ const DualWriteBlocksPanel = ({ return () => { cancelled = true; - window.clearTimeout(debounceRef.current); + // Navigating away lands right after a keystroke, and the buffer block is deleted next. + flushPendingChanges(); if (pullWatchArgsRef.current) { window.roamAlphaAPI.data.removePullWatch(...pullWatchArgsRef.current); pullWatchArgsRef.current = null; } }; - }, [renderUid, handleChange]); + }, [renderUid, handleChange, flushPendingChanges]); return ( <> - + {title ? ( + + ) : null}