From f8ccdd3e90ef43353482b5dcadbdb9dedf3e323f Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 11:27:59 +0530 Subject: [PATCH 1/6] ENG-1716: Render template settings from block props When the new settings store flag is on, materialize the block-props template into an ephemeral Template-Block-props block as the last child of node.type, render that, and delete it on unmount. Flag-off behavior is unchanged. --- .../components/EphemeralBlocksPanel.tsx | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 076cd4168..e6ccd24db 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -1,15 +1,20 @@ -import React, { useRef, useEffect, useCallback } from "react"; +import React, { useRef, useEffect, useCallback, useState } from "react"; import { Label } from "@blueprintjs/core"; import Description from "roamjs-components/components/Description"; import createBlock from "roamjs-components/writes/createBlock"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; import type { InputTextNode, TreeNode } from "roamjs-components/types"; import type { RoamNodeType } from "~/components/settings/utils/zodSchema"; -import { setDiscourseNodeSetting } from "~/components/settings/utils/accessors"; +import { + isNewSettingsStoreEnabled, + setDiscourseNodeSetting, +} from "~/components/settings/utils/accessors"; import type { DiscourseNodeBaseProps } from "./BlockPropSettingPanels"; const DEBOUNCE_MS = 250; +const TEMPLATE_BUFFER_TEXT = "Template-Block-props"; type DualWriteBlocksPanelProps = DiscourseNodeBaseProps & { uid: string; @@ -44,21 +49,46 @@ const DualWriteBlocksPanel = ({ [string, string, (before: unknown, after: unknown) => void] | null >(null); + const useNewStore = isNewSettingsStoreEnabled(); + const [bufferUid, setBufferUid] = useState(null); + const renderUid = useNewStore ? bufferUid : uid; + + useEffect(() => { + if (!useNewStore || !nodeType) return; + let cancelled = false; + const newUid = window.roamAlphaAPI.util.generateUID(); + const dv = defaultValueRef.current; + const seed: InputTextNode[] = dv && dv.length > 0 ? dv : [{ text: " " }]; + void createBlock({ + node: { text: TEMPLATE_BUFFER_TEXT, uid: newUid, children: seed }, + parentUid: nodeType, + order: "last", + }).then(() => { + if (!cancelled) setBufferUid(newUid); + }); + return () => { + cancelled = true; + setBufferUid(null); + void deleteBlock(newUid); + }; + }, [useNewStore, nodeType]); + const handleChange = useCallback(() => { + if (!renderUid) return; window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { - const tree = getFullTreeByParentUid(uid); + const tree = getFullTreeByParentUid(renderUid); const serialized = serializeBlockTree(tree.children); setDiscourseNodeSetting(nodeType, settingKeys, serialized); }, DEBOUNCE_MS); - }, [uid, nodeType, settingKeys]); + }, [renderUid, nodeType, settingKeys]); useEffect(() => { const el = containerRef.current; - if (!el || !uid) return; + if (!el || !renderUid) return; const pattern = "[:block/string :block/order {:block/children ...}]"; - const entityId = `[:block/uid "${uid}"]`; + const entityId = `[:block/uid "${renderUid}"]`; const callback = () => handleChange(); const registerPullWatch = () => { @@ -67,20 +97,23 @@ const DualWriteBlocksPanel = ({ }; const dv = defaultValueRef.current; - const ensureChildren = getFirstChildUidByBlockUid(uid) + const ensureChildren = getFirstChildUidByBlockUid(renderUid) ? Promise.resolve() : (dv && dv.length > 0 ? Promise.all( dv.map((node, i) => - createBlock({ node, parentUid: uid, order: i }), + createBlock({ node, parentUid: renderUid, order: i }), ), ) - : createBlock({ node: { text: " " }, parentUid: uid }) + : createBlock({ node: { text: " " }, parentUid: renderUid }) ).then(() => {}); void ensureChildren.then(() => { el.innerHTML = ""; - void window.roamAlphaAPI.ui.components.renderBlock({ uid, el }); + void window.roamAlphaAPI.ui.components.renderBlock({ + uid: renderUid, + el, + }); registerPullWatch(); }); @@ -91,7 +124,7 @@ const DualWriteBlocksPanel = ({ pullWatchArgsRef.current = null; } }; - }, [uid, handleChange]); + }, [renderUid, handleChange]); return ( <> From e868c9fddbcaa894c78ec59c4ecec22aaf17d3b8 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 13:32:00 +0530 Subject: [PATCH 2/6] ENG-1716: Dual-write template edits back to legacy Template block When the new settings store flag is on, edits in the buffer Template-Block-props block are mirrored to the legacy Template block via position-walked block.update calls. On length mismatch (only expected during testing/manual divergence) the mirror logs a warning and skips that subtree. --- .../components/EphemeralBlocksPanel.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index e6ccd24db..437477921 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -33,6 +33,31 @@ const serializeBlockTree = (children: TreeNode[]): RoamNodeType[] => }), })); +const mirrorBufferToLegacyChildren = ( + bufferChildren: TreeNode[], + legacyChildren: TreeNode[], +): void => { + if (bufferChildren.length !== legacyChildren.length) { + console.warn( + "[Template-Block-props] mirror skipped: tree length mismatch (expected only during testing or manual divergence)", + { buffer: bufferChildren.length, legacy: legacyChildren.length }, + ); + return; + } + const sortedBuffer = [...bufferChildren].sort((a, b) => a.order - b.order); + const sortedLegacy = [...legacyChildren].sort((a, b) => a.order - b.order); + for (let i = 0; i < sortedBuffer.length; i++) { + const bufferNode = sortedBuffer[i]; + const legacyNode = sortedLegacy[i]; + if (bufferNode.text !== legacyNode.text) { + void window.roamAlphaAPI.data.block.update({ + block: { uid: legacyNode.uid, string: bufferNode.text }, + }); + } + mirrorBufferToLegacyChildren(bufferNode.children, legacyNode.children); + } +}; + const DualWriteBlocksPanel = ({ nodeType, settingKeys, @@ -80,8 +105,12 @@ const DualWriteBlocksPanel = ({ const tree = getFullTreeByParentUid(renderUid); const serialized = serializeBlockTree(tree.children); setDiscourseNodeSetting(nodeType, settingKeys, serialized); + if (useNewStore && renderUid !== uid) { + const legacyTree = getFullTreeByParentUid(uid); + mirrorBufferToLegacyChildren(tree.children, legacyTree.children); + } }, DEBOUNCE_MS); - }, [renderUid, nodeType, settingKeys]); + }, [renderUid, uid, useNewStore, nodeType, settingKeys]); useEffect(() => { const el = containerRef.current; From cec980a77d2d8b90d5d4a85aadd074d75edc6f0a Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 16:12:27 +0530 Subject: [PATCH 3/6] ENG-1716: Extend buffer->legacy mirror to handle add/delete Mirror now creates legacy children when buffer has extras and deletes legacy children when buffer has fewer, in addition to in-place block.update for matching positions. Replaces the prior length-match guard which caused dual-write to silently skip whenever the user added or removed template lines. --- .../components/EphemeralBlocksPanel.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 437477921..ee99332b3 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -33,20 +33,27 @@ const serializeBlockTree = (children: TreeNode[]): RoamNodeType[] => }), })); +const treeNodeToInputTextNode = (node: TreeNode): InputTextNode => ({ + text: node.text, + ...(node.heading && { heading: node.heading as 0 | 1 | 2 | 3 }), + ...(node.open === false && { open: false }), + ...(node.children.length > 0 && { + children: [...node.children] + .sort((a, b) => a.order - b.order) + .map(treeNodeToInputTextNode), + }), +}); + const mirrorBufferToLegacyChildren = ( bufferChildren: TreeNode[], legacyChildren: TreeNode[], + legacyParentUid: string, ): void => { - if (bufferChildren.length !== legacyChildren.length) { - console.warn( - "[Template-Block-props] mirror skipped: tree length mismatch (expected only during testing or manual divergence)", - { buffer: bufferChildren.length, legacy: legacyChildren.length }, - ); - return; - } const sortedBuffer = [...bufferChildren].sort((a, b) => a.order - b.order); const sortedLegacy = [...legacyChildren].sort((a, b) => a.order - b.order); - for (let i = 0; i < sortedBuffer.length; i++) { + const minLen = Math.min(sortedBuffer.length, sortedLegacy.length); + + for (let i = 0; i < minLen; i++) { const bufferNode = sortedBuffer[i]; const legacyNode = sortedLegacy[i]; if (bufferNode.text !== legacyNode.text) { @@ -54,7 +61,20 @@ const mirrorBufferToLegacyChildren = ( block: { uid: legacyNode.uid, string: bufferNode.text }, }); } - mirrorBufferToLegacyChildren(bufferNode.children, legacyNode.children); + mirrorBufferToLegacyChildren( + bufferNode.children, + legacyNode.children, + legacyNode.uid, + ); + } + + for (let i = minLen; i < sortedBuffer.length; i++) { + const node = treeNodeToInputTextNode(sortedBuffer[i]); + void createBlock({ node, parentUid: legacyParentUid, order: i }); + } + + for (let i = minLen; i < sortedLegacy.length; i++) { + void deleteBlock(sortedLegacy[i].uid); } }; @@ -96,7 +116,7 @@ const DualWriteBlocksPanel = ({ setBufferUid(null); void deleteBlock(newUid); }; - }, [useNewStore, nodeType]); + }, [useNewStore, nodeType, uid]); const handleChange = useCallback(() => { if (!renderUid) return; @@ -107,7 +127,7 @@ const DualWriteBlocksPanel = ({ setDiscourseNodeSetting(nodeType, settingKeys, serialized); if (useNewStore && renderUid !== uid) { const legacyTree = getFullTreeByParentUid(uid); - mirrorBufferToLegacyChildren(tree.children, legacyTree.children); + mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); } }, DEBOUNCE_MS); }, [renderUid, uid, useNewStore, nodeType, settingKeys]); From ab16de6b9b23b358604eba9564a8db16a0b04df5 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 17:01:51 +0530 Subject: [PATCH 4/6] ENG-1716: Guard render effect against stale async continuation The ensureChildren promise resolves asynchronously, so its .then callback could run after the effect's cleanup fired - re-registering a pull watch on a stale renderUid and stomping pullWatchArgsRef. Add a cancelled flag (same pattern as the buffer-lifecycle effect) so the continuation aborts after teardown. --- .../components/settings/components/EphemeralBlocksPanel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index ee99332b3..69c2fb94d 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -136,6 +136,7 @@ const DualWriteBlocksPanel = ({ const el = containerRef.current; if (!el || !renderUid) return; + let cancelled = false; const pattern = "[:block/string :block/order {:block/children ...}]"; const entityId = `[:block/uid "${renderUid}"]`; const callback = () => handleChange(); @@ -158,6 +159,7 @@ const DualWriteBlocksPanel = ({ ).then(() => {}); void ensureChildren.then(() => { + if (cancelled) return; el.innerHTML = ""; void window.roamAlphaAPI.ui.components.renderBlock({ uid: renderUid, @@ -167,6 +169,7 @@ const DualWriteBlocksPanel = ({ }); return () => { + cancelled = true; window.clearTimeout(debounceRef.current); if (pullWatchArgsRef.current) { window.roamAlphaAPI.data.removePullWatch(...pullWatchArgsRef.current); From bbdf551e92d9a85e78e4672aae703fe770ce7c2f Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 18:09:57 +0530 Subject: [PATCH 5/6] ENG-1716: Mirror heading and open alongside string The mirror only pushed block string to legacy, so a heading-level or collapse-state change in the buffer never propagated. Update the same block.update call to include heading and open whenever any of the three differ between buffer and legacy. Also drops uid from the buffer-creation effect's dependency array since the body no longer references it. --- .../components/EphemeralBlocksPanel.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 69c2fb94d..943628a9c 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -56,9 +56,20 @@ const mirrorBufferToLegacyChildren = ( for (let i = 0; i < minLen; i++) { const bufferNode = sortedBuffer[i]; const legacyNode = sortedLegacy[i]; - if (bufferNode.text !== legacyNode.text) { + if ( + bufferNode.text !== legacyNode.text || + bufferNode.heading !== legacyNode.heading || + bufferNode.open !== legacyNode.open + ) { void window.roamAlphaAPI.data.block.update({ - block: { uid: legacyNode.uid, string: bufferNode.text }, + block: { + uid: legacyNode.uid, + string: bufferNode.text, + ...(bufferNode.heading !== undefined && { + heading: bufferNode.heading, + }), + ...(bufferNode.open !== undefined && { open: bufferNode.open }), + }, }); } mirrorBufferToLegacyChildren( @@ -116,7 +127,7 @@ const DualWriteBlocksPanel = ({ setBufferUid(null); void deleteBlock(newUid); }; - }, [useNewStore, nodeType, uid]); + }, [useNewStore, nodeType]); const handleChange = useCallback(() => { if (!renderUid) return; From 94733433afc0966615a75e70610d5ce161ef49bf Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 11 May 2026 18:21:00 +0530 Subject: [PATCH 6/6] ENG-1716: Rename useNewStore -> isNewStore useNewStore reads like a React hook (use* convention) but is a boolean. Rename to isNewStore so the name matches the type. --- .../settings/components/EphemeralBlocksPanel.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 943628a9c..64666c3bd 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -105,12 +105,12 @@ const DualWriteBlocksPanel = ({ [string, string, (before: unknown, after: unknown) => void] | null >(null); - const useNewStore = isNewSettingsStoreEnabled(); + const isNewStore = isNewSettingsStoreEnabled(); const [bufferUid, setBufferUid] = useState(null); - const renderUid = useNewStore ? bufferUid : uid; + const renderUid = isNewStore ? bufferUid : uid; useEffect(() => { - if (!useNewStore || !nodeType) return; + if (!isNewStore || !nodeType) return; let cancelled = false; const newUid = window.roamAlphaAPI.util.generateUID(); const dv = defaultValueRef.current; @@ -127,7 +127,7 @@ const DualWriteBlocksPanel = ({ setBufferUid(null); void deleteBlock(newUid); }; - }, [useNewStore, nodeType]); + }, [isNewStore, nodeType]); const handleChange = useCallback(() => { if (!renderUid) return; @@ -136,12 +136,12 @@ const DualWriteBlocksPanel = ({ const tree = getFullTreeByParentUid(renderUid); const serialized = serializeBlockTree(tree.children); setDiscourseNodeSetting(nodeType, settingKeys, serialized); - if (useNewStore && renderUid !== uid) { + if (isNewStore && renderUid !== uid) { const legacyTree = getFullTreeByParentUid(uid); mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); } }, DEBOUNCE_MS); - }, [renderUid, uid, useNewStore, nodeType, settingKeys]); + }, [renderUid, uid, isNewStore, nodeType, settingKeys]); useEffect(() => { const el = containerRef.current;