From 630d26a2de92f8a267d52f1f0a1748d26dca7e55 Mon Sep 17 00:00:00 2001 From: sid597 Date: Tue, 19 May 2026 19:03:56 +0530 Subject: [PATCH 1/7] Add Roam performance diagnostics --- .../components/settings/utils/accessors.ts | 148 +++++--- apps/roam/src/utils/getDiscourseNodes.ts | 202 ++++++---- .../utils/initializeObserversAndListeners.ts | 352 +++++++++++++----- .../roam/src/utils/pageRefObserverHandlers.ts | 301 ++++++++++----- apps/roam/src/utils/performanceLogger.ts | 228 ++++++++++++ 5 files changed, 890 insertions(+), 341 deletions(-) create mode 100644 apps/roam/src/utils/performanceLogger.ts diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index e5489322a..8d6effcfc 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -9,6 +9,7 @@ import { getSubTree } from "roamjs-components/util"; import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; import internalError from "~/utils/internalError"; import { getSetting } from "~/utils/extensionSettings"; +import { withPerformanceTrace } from "~/utils/performanceLogger"; import type { RoamBasicNode } from "roamjs-components/types"; import discourseConfigRef from "~/utils/discourseConfigRef"; @@ -1134,64 +1135,95 @@ const migrateNodeBlockProps = ( }; export const getAllDiscourseNodes = (): DiscourseNode[] => { - const results = window.roamAlphaAPI.data.fast.q(` - [:find ?uid ?title (pull ?page [:block/props]) - :where - [?page :node/title ?title] - [?page :block/uid ?uid] - [(clojure.string/starts-with? ?title "${DISCOURSE_NODE_PAGE_PREFIX}")]] - `) as [string, string, Record | null][]; - - const nodes: DiscourseNode[] = []; - - for (const [pageUid, title, rawProps] of results) { - if (typeof pageUid !== "string" || typeof title !== "string") continue; - const rawBlockProps = rawProps?.[":block/props"]; - const blockProps = rawBlockProps - ? normalizeProps(rawBlockProps) - : undefined; - if ( - !blockProps || - !isRecord(blockProps) || - Object.keys(blockProps).length === 0 - ) - continue; - - const nodeText = title.replace(DISCOURSE_NODE_PAGE_PREFIX, ""); - const result = DiscourseNodeSchema.safeParse(blockProps); - if (result.success) { - nodes.push( - toDiscourseNode({ - ...result.data, - type: pageUid, - text: nodeText, - }), - ); - } else { - // Try migrating legacy field shapes before dropping the node. - const migrated = migrateNodeBlockProps( - blockProps as Record, - ); - const retryResult = DiscourseNodeSchema.safeParse(migrated); - if (retryResult.success) { - setBlockProps(pageUid, retryResult.data, false); - nodes.push( - toDiscourseNode({ - ...retryResult.data, - type: pageUid, - text: nodeText, - }), - ); - } else { - internalError({ - error: retryResult.error, - type: "DG Discourse Node Parse", - context: { pageUid, title }, - sendEmail: false, - }); + let rawResultCount = 0; + let nodeCount = 0; + let skippedCount = 0; + let migratedCount = 0; + let parseErrorCount = 0; + + return withPerformanceTrace( + { + label: "getAllDiscourseNodes", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ + rawResultCount, + nodeCount, + skippedCount, + migratedCount, + parseErrorCount, + }), + }, + () => { + const results = window.roamAlphaAPI.data.fast.q(` + [:find ?uid ?title (pull ?page [:block/props]) + :where + [?page :node/title ?title] + [?page :block/uid ?uid] + [(clojure.string/starts-with? ?title "${DISCOURSE_NODE_PAGE_PREFIX}")]] + `) as [string, string, Record | null][]; + rawResultCount = results.length; + + const nodes: DiscourseNode[] = []; + + for (const [pageUid, title, rawProps] of results) { + if (typeof pageUid !== "string" || typeof title !== "string") { + skippedCount += 1; + continue; + } + const rawBlockProps = rawProps?.[":block/props"]; + const blockProps = rawBlockProps + ? normalizeProps(rawBlockProps) + : undefined; + if ( + !blockProps || + !isRecord(blockProps) || + Object.keys(blockProps).length === 0 + ) { + skippedCount += 1; + continue; + } + + const nodeText = title.replace(DISCOURSE_NODE_PAGE_PREFIX, ""); + const result = DiscourseNodeSchema.safeParse(blockProps); + if (result.success) { + nodes.push( + toDiscourseNode({ + ...result.data, + type: pageUid, + text: nodeText, + }), + ); + } else { + // Try migrating legacy field shapes before dropping the node. + const migrated = migrateNodeBlockProps( + blockProps as Record, + ); + const retryResult = DiscourseNodeSchema.safeParse(migrated); + if (retryResult.success) { + setBlockProps(pageUid, retryResult.data, false); + migratedCount += 1; + nodes.push( + toDiscourseNode({ + ...retryResult.data, + type: pageUid, + text: nodeText, + }), + ); + } else { + parseErrorCount += 1; + internalError({ + error: retryResult.error, + type: "DG Discourse Node Parse", + context: { pageUid, title }, + sendEmail: false, + }); + } + } } - } - } - return nodes; + nodeCount = nodes.length; + return nodes; + }, + ); }; diff --git a/apps/roam/src/utils/getDiscourseNodes.ts b/apps/roam/src/utils/getDiscourseNodes.ts index 765d12422..d1c72fe3c 100644 --- a/apps/roam/src/utils/getDiscourseNodes.ts +++ b/apps/roam/src/utils/getDiscourseNodes.ts @@ -10,6 +10,7 @@ import getDiscourseRelations from "./getDiscourseRelations"; import { roamNodeToCondition } from "./parseQuery"; import { Condition } from "./types"; import { InputTextNode, RoamBasicNode } from "roamjs-components/types"; +import { withPerformanceTrace } from "./performanceLogger"; export const excludeDefaultNodes = (node: DiscourseNode) => { return node.backedBy !== "default"; @@ -111,92 +112,129 @@ const getDiscourseNodes = ( relations?: ReturnType, snapshot?: SettingsSnapshot, ) => { - const resolvedRelations = relations ?? getDiscourseRelations(snapshot); - const newStoreEnabled = snapshot - ? snapshot.featureFlags["Use new settings store"] - : isNewSettingsStoreEnabled(); - const configuredNodes = ( - newStoreEnabled - ? getAllDiscourseNodes() - : Object.entries(discourseConfigRef.nodes).map( - ([type, { text, children }]): DiscourseNode => { - const suggestiveRules = getSubTree({ - tree: children, - key: "Suggestive Rules", - }); - const embeddingBlockRef = getSubTree({ - tree: suggestiveRules.children, - key: "Embedding Block Ref", - }); + let relationCount = 0; + let configuredNodeCount = 0; + let relationNodeCount = 0; + let defaultNodeCount = 0; + let totalNodeCount = 0; + let newStoreEnabled = false; - return { - format: getSettingValueFromTree({ - tree: children, - key: "format", - }), - text, - shortcut: getSettingValueFromTree({ - tree: children, - key: "shortcut", - }), - tag: getSettingValueFromTree({ tree: children, key: "tag" }), - type, - specification: getSpecification(children), - backedBy: "user", - canvasSettings: Object.fromEntries( - getSubTree({ tree: children, key: "canvas" }).children.map( - (c) => [c.text, c.children[0]?.text || ""] as const, - ), - ), - graphOverview: - children.filter((c) => c.text === "Graph Overview").length > 0, - description: getSettingValueFromTree({ + return withPerformanceTrace( + { + label: "getDiscourseNodes", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ + hasRelationsArg: !!relations, + hasSnapshot: !!snapshot, + newStoreEnabled, + relationCount, + configuredNodeCount, + relationNodeCount, + defaultNodeCount, + totalNodeCount, + }), + }, + () => { + const resolvedRelations = relations ?? getDiscourseRelations(snapshot); + relationCount = resolvedRelations.length; + newStoreEnabled = snapshot + ? snapshot.featureFlags["Use new settings store"] + : isNewSettingsStoreEnabled(); + + const configuredBaseNodes = newStoreEnabled + ? getAllDiscourseNodes() + : Object.entries(discourseConfigRef.nodes).map( + ([type, { text, children }]): DiscourseNode => { + const suggestiveRules = getSubTree({ tree: children, - key: "description", - }), - template: getSubTree({ tree: children, key: "template" }) - .children, - embeddingRef: embeddingBlockRef?.children?.[0]?.text, - embeddingRefUid: embeddingBlockRef?.uid, - isFirstChild: getUidAndBooleanSetting({ + key: "Suggestive Rules", + }); + const embeddingBlockRef = getSubTree({ tree: suggestiveRules.children, - text: "First Child", - }), - }; - }, - ) - ).concat( - resolvedRelations - .filter((r) => r.triples.some((t) => t.some((n) => /anchor/i.test(n)))) - .map((r) => ({ - format: "", - text: r.label, - type: r.id, - shortcut: r.label.slice(0, 1), - tag: "", - specification: r.triples.map(([source, relation, target]) => ({ - type: "clause", - source: /anchor/i.test(source) ? r.label : source, - relation, - target: - target === "source" - ? r.source - : target === "destination" - ? r.destination - : /anchor/i.test(target) - ? r.label - : target, - uid: window.roamAlphaAPI.util.generateUID(), - })), - backedBy: "relation", - canvasSettings: {}, - })), - ); - const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); - const defaultNodes = DEFAULT_NODES.filter( - (n) => !configuredNodeTexts.has(n.text), + key: "Embedding Block Ref", + }); + + return { + format: getSettingValueFromTree({ + tree: children, + key: "format", + }), + text, + shortcut: getSettingValueFromTree({ + tree: children, + key: "shortcut", + }), + tag: getSettingValueFromTree({ tree: children, key: "tag" }), + type, + specification: getSpecification(children), + backedBy: "user", + canvasSettings: Object.fromEntries( + getSubTree({ tree: children, key: "canvas" }).children.map( + (c) => [c.text, c.children[0]?.text || ""] as const, + ), + ), + graphOverview: + children.filter((c) => c.text === "Graph Overview").length > + 0, + description: getSettingValueFromTree({ + tree: children, + key: "description", + }), + template: getSubTree({ tree: children, key: "template" }) + .children, + embeddingRef: embeddingBlockRef?.children?.[0]?.text, + embeddingRefUid: embeddingBlockRef?.uid, + isFirstChild: getUidAndBooleanSetting({ + tree: suggestiveRules.children, + text: "First Child", + }), + }; + }, + ); + configuredNodeCount = configuredBaseNodes.length; + + const relationNodes = resolvedRelations + .filter((r) => r.triples.some((t) => t.some((n) => /anchor/i.test(n)))) + .map( + (r): DiscourseNode => ({ + format: "", + text: r.label, + type: r.id, + shortcut: r.label.slice(0, 1), + tag: "", + specification: r.triples.map(([source, relation, target]) => ({ + type: "clause", + source: /anchor/i.test(source) ? r.label : source, + relation, + target: + target === "source" + ? r.source + : target === "destination" + ? r.destination + : /anchor/i.test(target) + ? r.label + : target, + uid: window.roamAlphaAPI.util.generateUID(), + })), + backedBy: "relation", + canvasSettings: {}, + }), + ); + relationNodeCount = relationNodes.length; + + const configuredNodes = configuredBaseNodes.concat(relationNodes); + const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); + const defaultNodes = DEFAULT_NODES.filter( + (n) => !configuredNodeTexts.has(n.text), + ); + defaultNodeCount = defaultNodes.length; + + const nodes = configuredNodes.concat(defaultNodes); + totalNodeCount = nodes.length; + return nodes; + }, ); - return configuredNodes.concat(defaultNodes); }; export default getDiscourseNodes; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index d7a4ee7c1..0c487d71e 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -64,6 +64,10 @@ import { PERSONAL_KEYS, GLOBAL_KEYS, } from "~/components/settings/utils/settingKeys"; +import { + withAsyncPerformanceTrace, + withPerformanceTrace, +} from "./performanceLogger"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -107,52 +111,109 @@ export const initObservers = ({ tag: "H1", className: "rm-title-display", callback: (e) => { - const h1 = e as HTMLHeadingElement; - const { title, uid } = getTitleAndUidFromHeader(h1); + let titleLength = 0; + let isDiscourseNode = false; + let isQuery = false; + let isCanvas = false; + let isSidebar = false; + let renderedCanvasReferences = false; + + withPerformanceTrace( + { + label: "observer:pageTitle", + thresholdMs: 16, + aggregateThresholdMs: 50, + details: () => ({ + titleLength, + isDiscourseNode, + isQuery, + isCanvas, + isSidebar, + renderedCanvasReferences, + }), + }, + () => { + const h1 = e as HTMLHeadingElement; + const { title, uid } = getTitleAndUidFromHeader(h1); + titleLength = title.length; + + const settings = bulkReadSettings(); + + const props = { title, h1, onloadArgs }; + + const node = findDiscourseNode({ + uid, + title, + snapshot: settings, + }); - const settings = bulkReadSettings(); + isDiscourseNode = !!node && node.backedBy !== "default"; + if (isDiscourseNode && node) { + renderDiscourseContext({ h1, uid }); + if (getFeatureFlag("Duplicate node alert enabled")) { + renderPossibleDuplicates(h1, title, node); + } + const linkedReferencesDiv = document.querySelector( + ".rm-reference-main", + ) as HTMLDivElement; + if (linkedReferencesDiv) { + renderCanvasReferences(linkedReferencesDiv, uid, onloadArgs); + renderedCanvasReferences = true; + } + } - const props = { title, h1, onloadArgs }; + isQuery = isQueryPage({ title, snapshot: settings }); + if (isQuery) { + renderQueryPage(props); + return; + } - const node = findDiscourseNode({ - uid, - title, - snapshot: settings, - }); + isCanvas = isCurrentPageCanvas({ title, h1, snapshot: settings }); + if (isCanvas) { + renderTldrawCanvas(props); + return; + } - const isDiscourseNode = node && node.backedBy !== "default"; - if (isDiscourseNode) { - renderDiscourseContext({ h1, uid }); - if (getFeatureFlag("Duplicate node alert enabled")) { - renderPossibleDuplicates(h1, title, node); - } - const linkedReferencesDiv = document.querySelector( - ".rm-reference-main", - ) as HTMLDivElement; - if (linkedReferencesDiv) { - renderCanvasReferences(linkedReferencesDiv, uid, onloadArgs); - } - } - 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); - } + isSidebar = isSidebarCanvas({ title, h1, snapshot: settings }); + if (isSidebar) { + renderTldrawCanvasInSidebar(props); + } + }, + ); }, }); const queryBlockObserver = createButtonObserver({ attribute: "query-block", - render: (b) => renderQueryBlock(b, onloadArgs), + render: (b) => + withPerformanceTrace( + { + label: "observer:queryBlock", + thresholdMs: 16, + aggregateThresholdMs: 50, + }, + () => renderQueryBlock(b, onloadArgs), + ), }); let batchedTagNodes: DiscourseNode[] | null = null; const getNodesForTagBatch = (): DiscourseNode[] => { if (batchedTagNodes === null) { - const settings = bulkReadSettings(); - batchedTagNodes = getDiscourseNodes(undefined, settings); + let nodeCount = 0; + batchedTagNodes = withPerformanceTrace( + { + label: "nodeTagPopupButtonObserver:getNodesForTagBatch", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ nodeCount }), + }, + () => { + const settings = bulkReadSettings(); + const nodes = getDiscourseNodes(undefined, settings); + nodeCount = nodes.length; + return nodes; + }, + ); queueMicrotask(() => { batchedTagNodes = null; }); @@ -164,23 +225,39 @@ export const initObservers = ({ className: "rm-page-ref--tag", tag: "SPAN", callback: (s: HTMLSpanElement) => { - const tag = s.getAttribute("data-tag"); - if (tag) { - const normalizedTag = getCleanTagText(tag); - - for (const node of getNodesForTagBatch()) { - const normalizedNodeTag = node.tag ? getCleanTagText(node.tag) : ""; - if (normalizedTag === normalizedNodeTag) { - renderNodeTagPopupButton(s, node, onloadArgs.extensionAPI); - const color = node.canvasSettings?.color ?? ""; - const tagStyles = color ? getNodeTagStyles(color) : {}; - if (tagStyles) { - Object.assign(s.style, tagStyles); + let tagLength = 0; + let rendered = false; + withPerformanceTrace( + { + label: "observer:nodeTagPopupButton", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ tagLength, rendered }), + }, + () => { + const tag = s.getAttribute("data-tag"); + tagLength = tag?.length ?? 0; + if (tag) { + const normalizedTag = getCleanTagText(tag); + + for (const node of getNodesForTagBatch()) { + const normalizedNodeTag = node.tag + ? getCleanTagText(node.tag) + : ""; + if (normalizedTag === normalizedNodeTag) { + renderNodeTagPopupButton(s, node, onloadArgs.extensionAPI); + rendered = true; + const color = node.canvasSettings?.color ?? ""; + const tagStyles = color ? getNodeTagStyles(color) : {}; + if (tagStyles) { + Object.assign(s.style, tagStyles); + } + break; + } } - break; } - } - } + }, + ); }, }); @@ -213,8 +290,17 @@ export const initObservers = ({ tag: "DIV", className: "rm-graph-view-control-panel__main-options", callback: (el) => { - const div = el as HTMLDivElement; - renderGraphOverviewExport(div); + withPerformanceTrace( + { + label: "observer:graphOverviewExport", + thresholdMs: 16, + aggregateThresholdMs: 50, + }, + () => { + const div = el as HTMLDivElement; + renderGraphOverviewExport(div); + }, + ); }, }); @@ -222,9 +308,21 @@ export const initObservers = ({ tag: "IMG", className: "rm-inline-img", callback: (img: HTMLElement) => { - if (img instanceof HTMLImageElement) { - renderImageToolsMenu(img, onloadArgs.extensionAPI); - } + let rendered = false; + withPerformanceTrace( + { + label: "observer:imageMenu", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ rendered }), + }, + () => { + if (img instanceof HTMLImageElement) { + renderImageToolsMenu(img, onloadArgs.extensionAPI); + rendered = true; + } + }, + ); }, }); @@ -241,18 +339,39 @@ export const initObservers = ({ 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(undefined, settings).some(({ type }) => - evt.oldURL.endsWith(type), - ) - ) { - refreshConfigTree(settings); - } + let checkedNodeCount = 0; + let matchedConfigPage = false; + let matchedNodeType = false; + let refreshed = false; + withPerformanceTrace( + { + label: "listener:hashChange", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ + checkedNodeCount, + matchedConfigPage, + matchedNodeType, + refreshed, + }), + }, + () => { + 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 + matchedConfigPage = + !!configPageUid && evt.oldURL.endsWith(configPageUid); + const nodes = getDiscourseNodes(undefined, settings); + checkedNodeCount = nodes.length; + matchedNodeType = nodes.some(({ type }) => evt.oldURL.endsWith(type)); + + if (matchedConfigPage || matchedNodeType) { + refreshConfigTree(settings); + refreshed = true; + } + }, + ); }; let globalTrigger = settings.globalSettings[GLOBAL_KEYS.trigger].trim(); @@ -291,20 +410,28 @@ export const initObservers = ({ useBody: true, className: "starred-pages-wrapper", callback: (el) => { - void (async () => { - const settings = bulkReadSettings(); - const isLeftSidebarEnabled = - settings.featureFlags["Enable left sidebar"]; - const container = el as HTMLDivElement; - if (isLeftSidebarEnabled) { - container.style.padding = "0"; - await mountLeftSidebar({ - wrapper: container, - onloadArgs, - initialSnapshot: settings, - }); - } - })(); + let isLeftSidebarEnabled = false; + void withAsyncPerformanceTrace( + { + label: "observer:leftSidebar", + thresholdMs: 16, + aggregateThresholdMs: 50, + details: () => ({ isLeftSidebarEnabled }), + }, + async () => { + const settings = bulkReadSettings(); + isLeftSidebarEnabled = settings.featureFlags["Enable left sidebar"]; + const container = el as HTMLDivElement; + if (isLeftSidebarEnabled) { + container.style.padding = "0"; + await mountLeftSidebar({ + wrapper: container, + onloadArgs, + initialSnapshot: settings, + }); + } + }, + ); }, }); @@ -410,37 +537,58 @@ export const initObservers = ({ }; const nodeCreationPopoverListener = debounce(() => { - const settings = bulkReadSettings(); - if (!settings.personalSettings[PERSONAL_KEYS.textSelectionPopup]) return; + let selectedTextLength = 0; + let hasBlockElement = false; + let rendered = false; + withPerformanceTrace( + { + label: "listener:selectionchange", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ + selectedTextLength, + hasBlockElement, + rendered, + }), + }, + () => { + const settings = bulkReadSettings(); + if (!settings.personalSettings[PERSONAL_KEYS.textSelectionPopup]) + return; - const selection = window.getSelection(); + const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || !selection.focusNode) { - removeTextSelectionPopup(); - return; - } + if (!selection || selection.rangeCount === 0 || !selection.focusNode) { + removeTextSelectionPopup(); + return; + } - const selectedText = selection.toString().trim(); + const selectedText = selection.toString().trim(); + selectedTextLength = selectedText.length; - if (!selectedText) { - removeTextSelectionPopup(); - return; - } + if (!selectedText) { + removeTextSelectionPopup(); + return; + } - const blockElement = findBlockElementFromSelection(); + const blockElement = findBlockElementFromSelection(); + hasBlockElement = !!blockElement; - if (blockElement) { - const textarea = blockElement.querySelector("textarea"); - if (!textarea) return; + if (blockElement) { + const textarea = blockElement.querySelector("textarea"); + if (!textarea) return; - renderTextSelectionPopup({ - extensionAPI: onloadArgs.extensionAPI, - blockElement, - textarea, - }); - } else { - removeTextSelectionPopup(); - } + renderTextSelectionPopup({ + extensionAPI: onloadArgs.extensionAPI, + blockElement, + textarea, + }); + rendered = true; + } else { + removeTextSelectionPopup(); + } + }, + ); }, 150); return { diff --git a/apps/roam/src/utils/pageRefObserverHandlers.ts b/apps/roam/src/utils/pageRefObserverHandlers.ts index cac2d362b..1ce12d56c 100644 --- a/apps/roam/src/utils/pageRefObserverHandlers.ts +++ b/apps/roam/src/utils/pageRefObserverHandlers.ts @@ -6,6 +6,7 @@ import { OnloadArgs } from "roamjs-components/types"; import { renderSuggestive as renderSuggestiveOverlay } from "~/components/SuggestiveModeOverlay"; import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import findDiscourseNode from "./findDiscourseNode"; +import { withPerformanceTrace } from "./performanceLogger"; const PAGE_REF_SELECTOR = "span.rm-page-ref"; const DISCOURSE_OVERLAY_CLASS = "roamjs-discourse-context-overlay"; @@ -42,7 +43,20 @@ const queueBatchCacheClear = (): void => { const getBatchDiscourseNodes = (): DiscourseNode[] => { if (batchDiscourseNodes) return batchDiscourseNodes; - batchDiscourseNodes = getDiscourseNodes(); + let nodeCount = 0; + batchDiscourseNodes = withPerformanceTrace( + { + label: "pageRefObserver:getBatchDiscourseNodes", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ nodeCount }), + }, + () => { + const nodes = getDiscourseNodes(); + nodeCount = nodes.length; + return nodes; + }, + ); queueBatchCacheClear(); return batchDiscourseNodes; }; @@ -53,18 +67,35 @@ const getPageRefDiscourseNodeStatus = ( const cached = pageRefDiscourseNodeCache.get(tag); if (cached) return cached; - const uid = getPageUidByPageTitle(tag); - const node = uid - ? findDiscourseNode({ + let uid = ""; + let isDiscourseNode = false; + const status = withPerformanceTrace( + { + label: "pageRefObserver:getPageRefDiscourseNodeStatus", + thresholdMs: 4, + aggregateThresholdMs: 50, + details: () => ({ + tagLength: tag.length, + hasUid: !!uid, + isDiscourseNode, + }), + }, + () => { + uid = getPageUidByPageTitle(tag); + const node = uid + ? findDiscourseNode({ + uid, + title: tag, + nodes: getBatchDiscourseNodes(), + }) + : false; + isDiscourseNode = !!node && node.backedBy !== "default"; + return { uid, - title: tag, - nodes: getBatchDiscourseNodes(), - }) - : false; - const status = { - uid, - isDiscourseNode: !!node && node.backedBy !== "default", - }; + isDiscourseNode, + }; + }, + ); pageRefDiscourseNodeCache.set(tag, status); queueBatchCacheClear(); return status; @@ -87,98 +118,142 @@ export const overlayPageRefHandler = ( s: HTMLSpanElement, onloadArgs: OnloadArgs, ) => { - if (s.parentElement && !s.parentElement.closest(".rm-page-ref")) { - if ( - s.closest(".rm-title-display, .rm-title-display-container") || - s.parentElement?.closest(".rm-title-display, .rm-title-display-container") - ) { - return; - } - const tag = - s.getAttribute("data-tag") || - s.parentElement.getAttribute("data-link-title"); - const hasOverlayAttribute = s.getAttribute(DISCOURSE_OVERLAY_ATTR); - const hasOverlayElement = - (s.hasAttribute("data-tag") && - Array.from(s.children).some( - (child) => - child instanceof HTMLSpanElement && - child.querySelector(`.${DISCOURSE_OVERLAY_CLASS}`), - )) || - (s.parentElement && - Array.from(s.parentElement.children).some( - (child) => - child instanceof HTMLSpanElement && - child.querySelector(`.${DISCOURSE_OVERLAY_CLASS}`), - )); - if ( - tag && - !hasOverlayAttribute && - !hasOverlayElement && - getPageRefDiscourseNodeStatus(tag).isDiscourseNode - ) { - s.setAttribute(DISCOURSE_OVERLAY_ATTR, "true"); - const parent = document.createElement("span"); - discourseOverlayRender({ - parent, - tag: tag.replace(/\\"/g, '"'), - onloadArgs, - }); - if (s.hasAttribute("data-tag")) { - s.appendChild(parent); - } else { - s.parentElement.appendChild(parent); + let tagLength = 0; + let rendered = false; + withPerformanceTrace( + { + label: "pageRefObserver:overlayPageRefHandler", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ tagLength, rendered }), + }, + () => { + if (s.parentElement && !s.parentElement.closest(".rm-page-ref")) { + if ( + s.closest(".rm-title-display, .rm-title-display-container") || + s.parentElement?.closest( + ".rm-title-display, .rm-title-display-container", + ) + ) { + return; + } + const tag = + s.getAttribute("data-tag") || + s.parentElement.getAttribute("data-link-title"); + tagLength = tag?.length ?? 0; + const hasOverlayAttribute = s.getAttribute(DISCOURSE_OVERLAY_ATTR); + const hasOverlayElement = + (s.hasAttribute("data-tag") && + Array.from(s.children).some( + (child) => + child instanceof HTMLSpanElement && + child.querySelector(`.${DISCOURSE_OVERLAY_CLASS}`), + )) || + (s.parentElement && + Array.from(s.parentElement.children).some( + (child) => + child instanceof HTMLSpanElement && + child.querySelector(`.${DISCOURSE_OVERLAY_CLASS}`), + )); + if ( + tag && + !hasOverlayAttribute && + !hasOverlayElement && + getPageRefDiscourseNodeStatus(tag).isDiscourseNode + ) { + s.setAttribute(DISCOURSE_OVERLAY_ATTR, "true"); + const parent = document.createElement("span"); + discourseOverlayRender({ + parent, + tag: tag.replace(/\\"/g, '"'), + onloadArgs, + }); + rendered = true; + if (s.hasAttribute("data-tag")) { + s.appendChild(parent); + } else { + s.parentElement.appendChild(parent); + } + } } - } - } + }, + ); }; export const suggestiveOverlayPageRefHandler = ( s: HTMLSpanElement, onloadArgs: OnloadArgs, ) => { - if (s.parentElement && !s.parentElement.closest(".rm-page-ref")) { - const tag = - s.getAttribute("data-tag") || - s.parentElement.getAttribute("data-link-title"); - if ( - tag && - !s.getAttribute(SUGGESTIVE_OVERLAY_ATTR) && - getPageRefDiscourseNodeStatus(tag).isDiscourseNode - ) { - s.setAttribute(SUGGESTIVE_OVERLAY_ATTR, "true"); - const parent = document.createElement("span"); - renderSuggestiveOverlay({ - parent, - tag: tag.replace(/\\"/g, '"'), - onloadArgs, - }); - if (s.hasAttribute("data-tag")) { - s.appendChild(parent); - } else { - s.parentElement.appendChild(parent); + let tagLength = 0; + let rendered = false; + withPerformanceTrace( + { + label: "pageRefObserver:suggestiveOverlayPageRefHandler", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ tagLength, rendered }), + }, + () => { + if (s.parentElement && !s.parentElement.closest(".rm-page-ref")) { + const tag = + s.getAttribute("data-tag") || + s.parentElement.getAttribute("data-link-title"); + tagLength = tag?.length ?? 0; + if ( + tag && + !s.getAttribute(SUGGESTIVE_OVERLAY_ATTR) && + getPageRefDiscourseNodeStatus(tag).isDiscourseNode + ) { + s.setAttribute(SUGGESTIVE_OVERLAY_ATTR, "true"); + const parent = document.createElement("span"); + renderSuggestiveOverlay({ + parent, + tag: tag.replace(/\\"/g, '"'), + onloadArgs, + }); + rendered = true; + if (s.hasAttribute("data-tag")) { + s.appendChild(parent); + } else { + s.parentElement.appendChild(parent); + } + } } - } - } + }, + ); }; export const previewPageRefHandler = (s: HTMLSpanElement) => { - const tag = - s.getAttribute("data-tag") || - s.parentElement?.getAttribute("data-link-title"); - if (tag && !s.getAttribute("data-roamjs-discourse-augment-tag")) { - s.setAttribute("data-roamjs-discourse-augment-tag", "true"); - const parent = document.createElement("span"); - previewRender({ - parent, - tag, - registerMouseEvents: ({ open, close }) => { - s.addEventListener("mouseenter", (e) => open(e.ctrlKey)); - s.addEventListener("mouseleave", close); - }, - }); - s.appendChild(parent); - } + let tagLength = 0; + let rendered = false; + withPerformanceTrace( + { + label: "pageRefObserver:previewPageRefHandler", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ tagLength, rendered }), + }, + () => { + const tag = + s.getAttribute("data-tag") || + s.parentElement?.getAttribute("data-link-title"); + tagLength = tag?.length ?? 0; + if (tag && !s.getAttribute("data-roamjs-discourse-augment-tag")) { + s.setAttribute("data-roamjs-discourse-augment-tag", "true"); + const parent = document.createElement("span"); + previewRender({ + parent, + tag, + registerMouseEvents: ({ open, close }) => { + s.addEventListener("mouseenter", (e) => open(e.ctrlKey)); + s.addEventListener("mouseleave", close); + }, + }); + rendered = true; + s.appendChild(parent); + } + }, + ); }; export const enablePageRefObserver = () => { @@ -189,7 +264,23 @@ export const enablePageRefObserver = () => { tag: "SPAN", className: "rm-page-ref", callback: (s: HTMLSpanElement) => { - pageRefObservers.forEach((f) => f(s)); + const tag = + s.getAttribute("data-tag") || + s.parentElement?.getAttribute("data-link-title"); + withPerformanceTrace( + { + label: "observer:pageRef", + thresholdMs: 8, + aggregateThresholdMs: 50, + details: () => ({ + handlerCount: pageRefObservers.size, + tagLength: tag?.length ?? 0, + }), + }, + () => { + pageRefObservers.forEach((f) => f(s)); + }, + ); }, }); return pageRefObserverRef.current; @@ -204,11 +295,23 @@ const disablePageRefObserver = () => { const applyHandlersToExistingPageRefs = ( handler: (s: HTMLSpanElement) => void, ) => { - const existingPageRefs = - document.querySelectorAll(PAGE_REF_SELECTOR); - existingPageRefs.forEach((pageRef) => { - handler(pageRef); - }); + let pageRefCount = 0; + withPerformanceTrace( + { + label: "pageRefObserver:applyHandlersToExistingPageRefs", + thresholdMs: 16, + aggregateThresholdMs: 50, + details: () => ({ pageRefCount }), + }, + () => { + const existingPageRefs = + document.querySelectorAll(PAGE_REF_SELECTOR); + pageRefCount = existingPageRefs.length; + existingPageRefs.forEach((pageRef) => { + handler(pageRef); + }); + }, + ); }; const removeOverlayElements = (overlayClass: string, attributeName: string) => { diff --git a/apps/roam/src/utils/performanceLogger.ts b/apps/roam/src/utils/performanceLogger.ts new file mode 100644 index 000000000..ae255f05f --- /dev/null +++ b/apps/roam/src/utils/performanceLogger.ts @@ -0,0 +1,228 @@ +type PerformanceDetail = string | number | boolean | null | undefined; + +type PerformanceDetails = Record; + +type PerformanceTraceOptions = { + label: string; + thresholdMs?: number; + aggregateThresholdMs?: number; + details?: PerformanceDetails | (() => PerformanceDetails); +}; + +type AggregateSample = { + count: number; + totalDurationMs: number; + slowestDurationMs: number; + thresholdMs: number; + details?: PerformanceDetails; +}; + +type PerformanceDebugWindow = Window & + typeof globalThis & { + dgPerformanceDebug?: boolean; + }; + +const SLOW_OPERATION_THRESHOLD_MS = 16; +const DEBUG_STORAGE_KEY = "dg:performance-debug"; + +const aggregateSamples = new Map(); +let aggregateFlushQueued = false; + +const getPerformanceNow = (): number => { + if ( + typeof performance !== "undefined" && + typeof performance.now === "function" + ) { + return performance.now(); + } + + return Date.now(); +}; + +const getPerformanceDebugWindow = (): PerformanceDebugWindow | undefined => { + if (typeof window === "undefined") return undefined; + return window as PerformanceDebugWindow; +}; + +const readPerformanceDebugStorage = (): boolean => { + const debugWindow = getPerformanceDebugWindow(); + if (!debugWindow) return false; + + try { + return debugWindow.localStorage?.getItem(DEBUG_STORAGE_KEY) === "true"; + } catch { + return false; + } +}; + +const isPerformanceDebugEnabled = (): boolean => { + const debugWindow = getPerformanceDebugWindow(); + return ( + debugWindow?.dgPerformanceDebug === true || readPerformanceDebugStorage() + ); +}; + +const resolveDetails = ( + details?: PerformanceDetails | (() => PerformanceDetails), +): PerformanceDetails => { + if (!details) return {}; + return typeof details === "function" ? details() : details; +}; + +const compactDetails = (details: PerformanceDetails): PerformanceDetails => { + return Object.fromEntries( + Object.entries(details).filter(([, value]) => value !== undefined), + ); +}; + +const formatDuration = (durationMs: number): string => { + return `${Math.round(durationMs * 10) / 10}ms`; +}; + +const logPerformance = ({ + label, + durationMs, + details, + aggregate, +}: { + label: string; + durationMs: number; + details?: PerformanceDetails; + aggregate?: Pick; +}): void => { + const compactedDetails = compactDetails(details ?? {}); + const message = aggregate + ? `[DG Performance] ${label}: ${formatDuration(durationMs)} total across ${ + aggregate.count + } calls; slowest ${formatDuration(aggregate.slowestDurationMs)}` + : `[DG Performance] ${label}: ${formatDuration(durationMs)}`; + + console.warn(message, compactedDetails); +}; + +const scheduleAggregateFlush = (): void => { + if (aggregateFlushQueued) return; + aggregateFlushQueued = true; + + const debugWindow = getPerformanceDebugWindow(); + if (debugWindow?.requestAnimationFrame) { + debugWindow.requestAnimationFrame(flushAggregateSamples); + return; + } + + globalThis.setTimeout(flushAggregateSamples, 0); +}; + +const recordAggregateSample = ({ + label, + durationMs, + thresholdMs, + details, +}: { + label: string; + durationMs: number; + thresholdMs: number; + details?: PerformanceDetails; +}): void => { + const existing = aggregateSamples.get(label); + const nextSample: AggregateSample = existing + ? { + ...existing, + count: existing.count + 1, + totalDurationMs: existing.totalDurationMs + durationMs, + slowestDurationMs: Math.max(existing.slowestDurationMs, durationMs), + thresholdMs: Math.min(existing.thresholdMs, thresholdMs), + details: + durationMs >= existing.slowestDurationMs ? details : existing.details, + } + : { + count: 1, + totalDurationMs: durationMs, + slowestDurationMs: durationMs, + thresholdMs, + details, + }; + + aggregateSamples.set(label, nextSample); + scheduleAggregateFlush(); +}; + +function flushAggregateSamples(): void { + aggregateFlushQueued = false; + const debugEnabled = isPerformanceDebugEnabled(); + + aggregateSamples.forEach((sample, label) => { + if (!debugEnabled && sample.totalDurationMs < sample.thresholdMs) return; + + logPerformance({ + label, + durationMs: sample.totalDurationMs, + details: sample.details, + aggregate: { + count: sample.count, + slowestDurationMs: sample.slowestDurationMs, + }, + }); + }); + + aggregateSamples.clear(); +} + +export const recordPerformanceDuration = ({ + label, + durationMs, + thresholdMs = SLOW_OPERATION_THRESHOLD_MS, + aggregateThresholdMs, + details, +}: PerformanceTraceOptions & { durationMs: number }): void => { + const resolvedDetails = resolveDetails(details); + + if (durationMs >= thresholdMs || isPerformanceDebugEnabled()) { + logPerformance({ + label, + durationMs, + details: resolvedDetails, + }); + } + + if (aggregateThresholdMs !== undefined) { + recordAggregateSample({ + label, + durationMs, + thresholdMs: aggregateThresholdMs, + details: resolvedDetails, + }); + } +}; + +export const withPerformanceTrace = ( + options: PerformanceTraceOptions, + fn: () => T, +): T => { + const start = getPerformanceNow(); + + try { + return fn(); + } finally { + recordPerformanceDuration({ + ...options, + durationMs: getPerformanceNow() - start, + }); + } +}; + +export const withAsyncPerformanceTrace = async ( + options: PerformanceTraceOptions, + fn: () => Promise, +): Promise => { + const start = getPerformanceNow(); + + try { + return await fn(); + } finally { + recordPerformanceDuration({ + ...options, + durationMs: getPerformanceNow() - start, + }); + } +}; From f8be48b970e8e410905ffce91d917a30a0bee60e Mon Sep 17 00:00:00 2001 From: sid597 Date: Tue, 19 May 2026 19:11:16 +0530 Subject: [PATCH 2/7] Make performance logs bulk-copyable --- apps/roam/src/utils/performanceLogger.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/utils/performanceLogger.ts b/apps/roam/src/utils/performanceLogger.ts index ae255f05f..720338651 100644 --- a/apps/roam/src/utils/performanceLogger.ts +++ b/apps/roam/src/utils/performanceLogger.ts @@ -79,6 +79,19 @@ const formatDuration = (durationMs: number): string => { return `${Math.round(durationMs * 10) / 10}ms`; }; +const formatDetailValue = (value: PerformanceDetail): string => { + if (typeof value === "string") return `"${value}"`; + return String(value); +}; + +const formatDetails = (details: PerformanceDetails): string => { + const formattedDetails = Object.entries(details) + .map(([key, value]) => `${key}: ${formatDetailValue(value)}`) + .join(", "); + if (!formattedDetails) return ""; + return ` { ${formattedDetails} }`; +}; + const logPerformance = ({ label, durationMs, @@ -97,7 +110,7 @@ const logPerformance = ({ } calls; slowest ${formatDuration(aggregate.slowestDurationMs)}` : `[DG Performance] ${label}: ${formatDuration(durationMs)}`; - console.warn(message, compactedDetails); + console.log(`${message}${formatDetails(compactedDetails)}`); }; const scheduleAggregateFlush = (): void => { From 9465c6af9434a4fde856b00481ed06babd8fded7 Mon Sep 17 00:00:00 2001 From: sid597 Date: Wed, 20 May 2026 22:32:58 +0530 Subject: [PATCH 3/7] Cache discourse node settings reads --- .../settings/DiscourseNodeConfigPanel.tsx | 8 ++- .../components/settings/utils/accessors.ts | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index 1556b3042..302e842dd 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -20,7 +20,11 @@ import { deleteBlock } from "roamjs-components/writes"; import { formatHexColor } from "./DiscourseNodeCanvasSettings"; import setBlockProps from "~/utils/setBlockProps"; import { DiscourseNodeSchema } from "./utils/zodSchema"; -import { getGlobalSettings, setGlobalSetting } from "./utils/accessors"; +import { + clearAllDiscourseNodesCache, + getGlobalSettings, + setGlobalSetting, +} from "./utils/accessors"; import { GLOBAL_KEYS } from "./utils/settingKeys"; type DiscourseNodeConfigPanelProps = React.ComponentProps< @@ -60,6 +64,7 @@ const DiscourseNodeConfigPanel: React.FC = ({ await window.roamAlphaAPI.deletePage({ page: { uid }, }); + clearAllDiscourseNodesCache(); setNodes((prevNodes) => prevNodes.filter((nn) => nn.type !== uid)); refreshConfigTree(); setDeleteConfirmation(null); @@ -109,6 +114,7 @@ const DiscourseNodeConfigPanel: React.FC = ({ format, }), ); + clearAllDiscourseNodesCache(); setNodes([ ...nodes, { diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 8d6effcfc..b238bd97b 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -203,6 +203,47 @@ const DEFAULT_LEGACY_QUERY = { returnNode: "node", }; +const ALL_DISCOURSE_NODES_CACHE_TTL_MS = 2000; + +type AllDiscourseNodesCacheEntry = { + nodes: DiscourseNode[]; + cachedAtMs: number; + rawResultCount: number; + skippedCount: number; + migratedCount: number; + parseErrorCount: number; +}; + +let allDiscourseNodesCache: AllDiscourseNodesCacheEntry | null = null; + +const getAllDiscourseNodesCacheNow = (): number => { + if ( + typeof performance !== "undefined" && + typeof performance.now === "function" + ) { + return performance.now(); + } + + return Date.now(); +}; + +export const clearAllDiscourseNodesCache = (): void => { + allDiscourseNodesCache = null; +}; + +const getCachedAllDiscourseNodes = (): AllDiscourseNodesCacheEntry | null => { + if (!allDiscourseNodesCache) return null; + + const ageMs = + getAllDiscourseNodesCacheNow() - allDiscourseNodesCache.cachedAtMs; + if (ageMs > ALL_DISCOURSE_NODES_CACHE_TTL_MS) { + clearAllDiscourseNodesCache(); + return null; + } + + return allDiscourseNodesCache; +}; + const PERSONAL_SCHEMA_PATH_TO_LEGACY_KEY = new Map([ [ pathKey([PERSONAL_KEYS.discourseContextOverlay]), @@ -653,6 +694,7 @@ const setBlockPropAtPath = ( }, updatedProps); setBlockProps(blockUid, updatedProps, false); + clearAllDiscourseNodesCache(); }; const setBlockPropBasedSettings = ({ @@ -1140,6 +1182,7 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { let skippedCount = 0; let migratedCount = 0; let parseErrorCount = 0; + let cacheHit = false; return withPerformanceTrace( { @@ -1152,9 +1195,21 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { skippedCount, migratedCount, parseErrorCount, + cacheHit, }), }, () => { + const cached = getCachedAllDiscourseNodes(); + if (cached) { + cacheHit = true; + rawResultCount = cached.rawResultCount; + nodeCount = cached.nodes.length; + skippedCount = cached.skippedCount; + migratedCount = cached.migratedCount; + parseErrorCount = cached.parseErrorCount; + return cached.nodes; + } + const results = window.roamAlphaAPI.data.fast.q(` [:find ?uid ?title (pull ?page [:block/props]) :where @@ -1223,6 +1278,14 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { } nodeCount = nodes.length; + allDiscourseNodesCache = { + nodes, + cachedAtMs: getAllDiscourseNodesCacheNow(), + rawResultCount, + skippedCount, + migratedCount, + parseErrorCount, + }; return nodes; }, ); From 8fa8a02d13bd15fb0f5b7cac15ca134ce0d4312a Mon Sep 17 00:00:00 2001 From: sid597 Date: Thu, 21 May 2026 21:41:38 +0530 Subject: [PATCH 4/7] Cache discourse node type reads --- .../settings/DiscourseNodeConfigPanel.tsx | 3 +++ .../components/settings/utils/accessors.ts | 20 +++++++++++++++++++ .../utils/migrateLegacyToBlockProps.ts | 5 +++++ apps/roam/src/utils/discourseNodeTypeCache.ts | 8 ++++++++ apps/roam/src/utils/findDiscourseNode.ts | 10 +++++++++- 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 apps/roam/src/utils/discourseNodeTypeCache.ts diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index f2bda0b83..01a2c289d 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -22,6 +22,7 @@ import setBlockProps from "~/utils/setBlockProps"; import { DiscourseNodeSchema } from "./utils/zodSchema"; import { getGlobalSettings, setGlobalSetting } from "./utils/accessors"; import { GLOBAL_KEYS } from "./utils/settingKeys"; +import { invalidateDiscourseNodeTypeCaches } from "~/utils/discourseNodeTypeCache"; type DiscourseNodeConfigPanelProps = React.ComponentProps< CustomField["options"]["component"] @@ -60,6 +61,7 @@ const DiscourseNodeConfigPanel: React.FC = ({ await window.roamAlphaAPI.deletePage({ page: { uid }, }); + invalidateDiscourseNodeTypeCaches(); setNodes((prevNodes) => prevNodes.filter((nn) => nn.type !== uid)); refreshConfigTree(); setDeleteConfirmation(null); @@ -117,6 +119,7 @@ const DiscourseNodeConfigPanel: React.FC = ({ format, }), ); + invalidateDiscourseNodeTypeCaches(); setNodes([ ...nodes, { diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 46fd70342..9c7dc6178 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -24,6 +24,10 @@ import { } from "~/utils/getExportSettings"; import { getSuggestiveModeConfigAndUids } from "~/utils/getSuggestiveModeConfigSettings"; import { getLeftSidebarSettings } from "~/utils/getLeftSidebarSettings"; +import { + getDiscourseNodeTypeCacheVersion, + invalidateDiscourseNodeTypeCaches, +} from "~/utils/discourseNodeTypeCache"; import { DG_BLOCK_PROP_SETTINGS_PAGE_TITLE, @@ -767,6 +771,10 @@ export const setFeatureFlag = ( keys: [STATIC_TOP_LEVEL_ENTRIES.featureFlags.key, key], value: validatedValue, }); + + if (key === "Use new settings store") { + invalidateDiscourseNodeTypeCaches(); + } }; export const getGlobalSettings = (): GlobalSettings => { @@ -1023,6 +1031,7 @@ export const setDiscourseNodeSetting = ( } setBlockPropAtPath(pageUid, keys, value); + invalidateDiscourseNodeTypeCaches(); }; const addConditionUids = (conditions: SchemaCondition[]): Condition[] => @@ -1131,7 +1140,17 @@ const migrateNodeBlockProps = ( return migrated; }; +let allDiscourseNodesCache: { + version: number; + nodes: DiscourseNode[]; +} | null = null; + export const getAllDiscourseNodes = (): DiscourseNode[] => { + const cacheVersion = getDiscourseNodeTypeCacheVersion(); + if (allDiscourseNodesCache?.version === cacheVersion) { + return allDiscourseNodesCache.nodes; + } + const results = window.roamAlphaAPI.data.fast.q(` [:find ?uid ?title (pull ?page [:block/props]) :where @@ -1191,5 +1210,6 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { } } + allDiscourseNodesCache = { version: cacheVersion, nodes }; return nodes; }; diff --git a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts index e026982a0..da29fa11b 100644 --- a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts +++ b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts @@ -23,6 +23,7 @@ import { getPersonalSettingsKey, } from "./zodSchema"; import type { z } from "zod"; +import { invalidateDiscourseNodeTypeCaches } from "~/utils/discourseNodeTypeCache"; const LOG_PREFIX = "[DG BlockProps Migration]"; const GRAPH_MIGRATION_MARKER = "Block props migrated"; @@ -63,11 +64,13 @@ const migrateSection = ({ blockUid, schema, legacyData, + onWrite, }: { label: string; blockUid: string; schema: z.ZodTypeAny; legacyData: Record; + onWrite?: () => void; }): boolean => { const currentProps = getBlockProps(blockUid); @@ -103,6 +106,7 @@ const migrateSection = ({ } setBlockProps(blockUid, parsedLegacy, false); + onWrite?.(); console.log(`${LOG_PREFIX} ${label}: migrated`); return true; }; @@ -156,6 +160,7 @@ const migrateDiscourseNodes = async (): Promise => { blockUid: nodePageUid, schema: DiscourseNodeSchema, legacyData, + onWrite: invalidateDiscourseNodeTypeCaches, }) ) { allOk = false; diff --git a/apps/roam/src/utils/discourseNodeTypeCache.ts b/apps/roam/src/utils/discourseNodeTypeCache.ts new file mode 100644 index 000000000..f5dc59a00 --- /dev/null +++ b/apps/roam/src/utils/discourseNodeTypeCache.ts @@ -0,0 +1,8 @@ +let discourseNodeTypeCacheVersion = 0; + +export const invalidateDiscourseNodeTypeCaches = (): void => { + discourseNodeTypeCacheVersion += 1; +}; + +export const getDiscourseNodeTypeCacheVersion = (): number => + discourseNodeTypeCacheVersion; diff --git a/apps/roam/src/utils/findDiscourseNode.ts b/apps/roam/src/utils/findDiscourseNode.ts index e0af95981..0c3e5ced3 100644 --- a/apps/roam/src/utils/findDiscourseNode.ts +++ b/apps/roam/src/utils/findDiscourseNode.ts @@ -1,8 +1,10 @@ import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import matchDiscourseNode from "./matchDiscourseNode"; import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; +import { getDiscourseNodeTypeCacheVersion } from "./discourseNodeTypeCache"; -const discourseNodeTypeCache: Record = {}; +let discourseNodeTypeCache: Record = {}; +let discourseNodeTypeCacheVersion = -1; const findDiscourseNode = ({ uid, @@ -15,6 +17,12 @@ const findDiscourseNode = ({ nodes?: DiscourseNode[]; snapshot?: SettingsSnapshot; }): DiscourseNode | false => { + const currentCacheVersion = getDiscourseNodeTypeCacheVersion(); + if (discourseNodeTypeCacheVersion !== currentCacheVersion) { + discourseNodeTypeCache = {}; + discourseNodeTypeCacheVersion = currentCacheVersion; + } + if (typeof discourseNodeTypeCache[uid] !== "undefined") { return discourseNodeTypeCache[uid]; } From 5e983ef870893d071cbc75b6bed44b5185f93cc0 Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 22 May 2026 12:24:44 +0530 Subject: [PATCH 5/7] Measure getDiscourseNodes step timings --- apps/roam/src/utils/getDiscourseNodes.ts | 137 ++++++++++++++++------- 1 file changed, 97 insertions(+), 40 deletions(-) diff --git a/apps/roam/src/utils/getDiscourseNodes.ts b/apps/roam/src/utils/getDiscourseNodes.ts index d1c72fe3c..e96411284 100644 --- a/apps/roam/src/utils/getDiscourseNodes.ts +++ b/apps/roam/src/utils/getDiscourseNodes.ts @@ -108,16 +108,42 @@ const getUidAndBooleanSetting = ({ }; }; +const getPerformanceNow = (): number => { + if ( + typeof performance !== "undefined" && + typeof performance.now === "function" + ) { + return performance.now(); + } + + return Date.now(); +}; + +const roundDurationMs = (durationMs: number): number => + Math.round(durationMs * 10) / 10; + +const measureStep = (fn: () => T): [T, number] => { + const start = getPerformanceNow(); + const result = fn(); + return [result, roundDurationMs(getPerformanceNow() - start)]; +}; + const getDiscourseNodes = ( relations?: ReturnType, snapshot?: SettingsSnapshot, -) => { +): DiscourseNode[] => { let relationCount = 0; let configuredNodeCount = 0; let relationNodeCount = 0; let defaultNodeCount = 0; let totalNodeCount = 0; let newStoreEnabled = false; + let resolveRelationsMs = 0; + let resolveStoreFlagMs = 0; + let configuredBaseNodesMs = 0; + let relationNodesMs = 0; + let defaultNodesMs = 0; + let concatNodesMs = 0; return withPerformanceTrace( { @@ -133,18 +159,36 @@ const getDiscourseNodes = ( relationNodeCount, defaultNodeCount, totalNodeCount, + resolveRelationsMs, + resolveStoreFlagMs, + configuredBaseNodesMs, + relationNodesMs, + defaultNodesMs, + concatNodesMs, }), }, () => { - const resolvedRelations = relations ?? getDiscourseRelations(snapshot); + const [resolvedRelations, measuredResolveRelationsMs] = measureStep( + () => relations ?? getDiscourseRelations(snapshot), + ); + resolveRelationsMs = measuredResolveRelationsMs; relationCount = resolvedRelations.length; - newStoreEnabled = snapshot - ? snapshot.featureFlags["Use new settings store"] - : isNewSettingsStoreEnabled(); + const [resolvedNewStoreEnabled, measuredResolveStoreFlagMs] = measureStep( + () => + snapshot + ? snapshot.featureFlags["Use new settings store"] + : isNewSettingsStoreEnabled(), + ); + newStoreEnabled = resolvedNewStoreEnabled; + resolveStoreFlagMs = measuredResolveStoreFlagMs; + + const [configuredBaseNodes, measuredConfiguredBaseNodesMs] = measureStep( + () => { + if (newStoreEnabled) { + return getAllDiscourseNodes(); + } - const configuredBaseNodes = newStoreEnabled - ? getAllDiscourseNodes() - : Object.entries(discourseConfigRef.nodes).map( + return Object.entries(discourseConfigRef.nodes).map( ([type, { text, children }]): DiscourseNode => { const suggestiveRules = getSubTree({ tree: children, @@ -192,45 +236,58 @@ const getDiscourseNodes = ( }; }, ); + }, + ); + configuredBaseNodesMs = measuredConfiguredBaseNodesMs; configuredNodeCount = configuredBaseNodes.length; - const relationNodes = resolvedRelations - .filter((r) => r.triples.some((t) => t.some((n) => /anchor/i.test(n)))) - .map( - (r): DiscourseNode => ({ - format: "", - text: r.label, - type: r.id, - shortcut: r.label.slice(0, 1), - tag: "", - specification: r.triples.map(([source, relation, target]) => ({ - type: "clause", - source: /anchor/i.test(source) ? r.label : source, - relation, - target: - target === "source" - ? r.source - : target === "destination" - ? r.destination - : /anchor/i.test(target) - ? r.label - : target, - uid: window.roamAlphaAPI.util.generateUID(), - })), - backedBy: "relation", - canvasSettings: {}, - }), - ); + const [relationNodes, measuredRelationNodesMs] = measureStep(() => { + return resolvedRelations + .filter((r) => + r.triples.some((t) => t.some((n) => /anchor/i.test(n))), + ) + .map( + (r): DiscourseNode => ({ + format: "", + text: r.label, + type: r.id, + shortcut: r.label.slice(0, 1), + tag: "", + specification: r.triples.map(([source, relation, target]) => ({ + type: "clause", + source: /anchor/i.test(source) ? r.label : source, + relation, + target: + target === "source" + ? r.source + : target === "destination" + ? r.destination + : /anchor/i.test(target) + ? r.label + : target, + uid: window.roamAlphaAPI.util.generateUID(), + })), + backedBy: "relation", + canvasSettings: {}, + }), + ); + }); + relationNodesMs = measuredRelationNodesMs; relationNodeCount = relationNodes.length; const configuredNodes = configuredBaseNodes.concat(relationNodes); - const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); - const defaultNodes = DEFAULT_NODES.filter( - (n) => !configuredNodeTexts.has(n.text), - ); + const [defaultNodes, measuredDefaultNodesMs] = measureStep(() => { + const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); + return DEFAULT_NODES.filter((n) => !configuredNodeTexts.has(n.text)); + }); + defaultNodesMs = measuredDefaultNodesMs; defaultNodeCount = defaultNodes.length; - const nodes = configuredNodes.concat(defaultNodes); + const [nodes, measuredConcatNodesMs] = measureStep(() => + configuredNodes.concat(defaultNodes), + ); + concatNodesMs = measuredConcatNodesMs; + totalNodeCount = nodes.length; return nodes; }, From 374d4ddc1f111b16636cc44716db49d837eef169 Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 22 May 2026 14:02:42 +0530 Subject: [PATCH 6/7] Trace discourse node performance call sources --- .../components/DiscourseContextOverlay.tsx | 8 +- .../roam/src/components/DiscourseNodeMenu.tsx | 27 +- apps/roam/src/components/SuggestionsBody.tsx | 5 +- .../components/settings/utils/accessors.ts | 360 ++++++++++++++---- apps/roam/src/index.ts | 5 +- .../src/utils/deriveDiscourseNodeAttribute.ts | 8 +- apps/roam/src/utils/findDiscourseNode.ts | 5 +- .../src/utils/getDiscourseContextResults.ts | 10 +- apps/roam/src/utils/getDiscourseNodes.ts | 145 +++---- apps/roam/src/utils/getDiscourseRelations.ts | 144 +++++-- apps/roam/src/utils/getExportTypes.ts | 8 +- apps/roam/src/utils/getRelationData.ts | 8 +- .../src/utils/initializeDiscourseNodes.ts | 7 +- .../utils/initializeObserversAndListeners.ts | 85 ++++- apps/roam/src/utils/isDiscourseNode.ts | 5 +- .../roam/src/utils/pageRefObserverHandlers.ts | 12 +- apps/roam/src/utils/performanceLogger.ts | 65 +++- .../registerDiscourseDatalogTranslators.ts | 8 +- apps/roam/src/utils/renderImageToolsMenu.tsx | 25 +- 19 files changed, 732 insertions(+), 208 deletions(-) diff --git a/apps/roam/src/components/DiscourseContextOverlay.tsx b/apps/roam/src/components/DiscourseContextOverlay.tsx index ae262442b..70b130132 100644 --- a/apps/roam/src/components/DiscourseContextOverlay.tsx +++ b/apps/roam/src/components/DiscourseContextOverlay.tsx @@ -37,8 +37,12 @@ const getOverlayInfo = async ( ignoreCache?: boolean, ): Promise => { try { - const relations = getDiscourseRelations(); - const nodes = getDiscourseNodes(relations); + const trace = { + source: "DiscourseContextOverlay:getOverlayInfo", + content: tag, + }; + const relations = getDiscourseRelations(undefined, trace); + const nodes = getDiscourseNodes(relations, undefined, trace); const [results, refs] = await Promise.all([ getDiscourseContextResults({ diff --git a/apps/roam/src/components/DiscourseNodeMenu.tsx b/apps/roam/src/components/DiscourseNodeMenu.tsx index 60680e00f..834b3351c 100644 --- a/apps/roam/src/components/DiscourseNodeMenu.tsx +++ b/apps/roam/src/components/DiscourseNodeMenu.tsx @@ -30,6 +30,7 @@ import posthog from "posthog-js"; import { setPersonalSetting } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; import type { PersonalSettings } from "~/components/settings/utils/zodSchema"; +import type { PerformanceTraceContext } from "~/utils/performanceLogger"; type Props = { textarea?: HTMLTextAreaElement; @@ -38,6 +39,22 @@ type Props = { trigger?: JSX.Element; isShift?: boolean; menuMaxHeight?: number; + trace?: PerformanceTraceContext; +}; + +const compactTraceContent = (content?: string): string | undefined => { + const compacted = content?.replace(/\s+/g, " ").trim(); + return compacted ? compacted.slice(0, 120) : undefined; +}; + +const getNodeMenuTraceContent = ({ + trace, + textarea, + blockUid, +}: Pick): string | undefined => { + if (trace?.content) return compactTraceContent(trace.content); + if (blockUid) return `blockUid:${blockUid}`; + return compactTraceContent(textarea?.value); }; const NodeMenu = ({ @@ -48,6 +65,7 @@ const NodeMenu = ({ trigger, isShift, menuMaxHeight, + trace, }: { onClose: () => void } & Props) => { const isInitialTextSelected = !!textarea && textarea.selectionStart !== textarea.selectionEnd; @@ -56,8 +74,13 @@ const NodeMenu = ({ isInitialTextSelected || (isShift ?? false), ); const userDiscourseNodes = useMemo( - () => getDiscourseNodes().filter((n) => n.backedBy === "user"), - [], + () => + getDiscourseNodes(undefined, undefined, { + source: + trace?.source ?? "component:DiscourseNodeMenu:userDiscourseNodes", + content: getNodeMenuTraceContent({ trace, textarea, blockUid }), + }).filter((n) => n.backedBy === "user"), + [blockUid, textarea, trace?.content, trace?.source], ); const discourseNodes = userDiscourseNodes.filter( (n) => showNodeTypes || n.tag, diff --git a/apps/roam/src/components/SuggestionsBody.tsx b/apps/roam/src/components/SuggestionsBody.tsx index 7f751e31f..a74fb5e03 100644 --- a/apps/roam/src/components/SuggestionsBody.tsx +++ b/apps/roam/src/components/SuggestionsBody.tsx @@ -51,7 +51,10 @@ const getOverlayInfo = async ( try { if (cache[tag]) return cache[tag]; - const nodes = getDiscourseNodes(relations); + const nodes = getDiscourseNodes(relations, undefined, { + source: "SuggestionsBody:getOverlayInfo", + content: tag, + }); const [results, refs] = await Promise.all([ getDiscourseContextResults({ diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index f843e613f..4d64cf730 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -9,7 +9,12 @@ import { getSubTree } from "roamjs-components/util"; import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; import internalError from "~/utils/internalError"; import { getSetting } from "~/utils/extensionSettings"; -import { withPerformanceTrace } from "~/utils/performanceLogger"; +import { + measurePerformanceStep, + resolvePerformanceTraceContext, + withPerformanceTrace, + type PerformanceTraceArg, +} from "~/utils/performanceLogger"; import type { RoamBasicNode } from "roamjs-components/types"; import discourseConfigRef from "~/utils/discourseConfigRef"; @@ -727,12 +732,66 @@ const FEATURE_FLAG_LEGACY_MAP: Record< }; /* eslint-enable @typescript-eslint/naming-convention */ -export const getFeatureFlag = (key: keyof FeatureFlags): boolean => { - return bulkReadSettings().featureFlags[key]; +export const getFeatureFlag = ( + key: keyof FeatureFlags, + trace?: PerformanceTraceArg, +): boolean => { + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getFeatureFlag"], + }); + let value = false; + + return withPerformanceTrace( + { + label: "getFeatureFlag", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + key, + value, + }), + }, + () => { + value = bulkReadSettings({ traceId, source, content }).featureFlags[key]; + return value; + }, + ); }; -export const isNewSettingsStoreEnabled = (): boolean => { - return getFeatureFlag("Use new settings store"); +export const isNewSettingsStoreEnabled = ( + trace?: PerformanceTraceArg, +): boolean => { + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["isNewSettingsStoreEnabled"], + }); + let value = false; + + return withPerformanceTrace( + { + label: "isNewSettingsStoreEnabled", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + value, + }), + }, + () => { + value = getFeatureFlag("Use new settings store", { + traceId, + source, + content, + }); + return value; + }, + ); }; export const readAllLegacyFeatureFlags = (): Partial => { @@ -789,8 +848,37 @@ export const setFeatureFlag = ( } }; -export const getGlobalSettings = (): GlobalSettings => { - return bulkReadSettings().globalSettings; +export const getGlobalSettings = ( + trace?: PerformanceTraceArg, +): GlobalSettings => { + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getGlobalSettings"], + }); + let relationDefinitionCount = 0; + + return withPerformanceTrace( + { + label: "getGlobalSettings", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + relationDefinitionCount, + }), + }, + () => { + const globalSettings = bulkReadSettings({ + traceId, + source, + content, + }).globalSettings; + relationDefinitionCount = Object.keys(globalSettings.Relations).length; + return globalSettings; + }, + ); }; export const getGlobalSetting = ( @@ -830,20 +918,60 @@ export const setGlobalSetting = (keys: string[], value: json): void => { export const getAllRelations = ( settings?: SettingsSnapshot, + trace?: PerformanceTraceArg, ): DiscourseRelation[] => { - const globalSettings = settings - ? settings.globalSettings - : getGlobalSettings(); - - return Object.entries(globalSettings.Relations).flatMap(([id, relation]) => - relation.ifConditions.map((ifCondition) => ({ - id, - label: relation.label, - source: relation.source, - destination: relation.destination, - complement: relation.complement, - triples: ifCondition.triples, - })), + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getAllRelations"], + }); + let relationDefinitionCount = 0; + let relationCount = 0; + let resolveGlobalSettingsMs = 0; + let flattenRelationsMs = 0; + + return withPerformanceTrace( + { + label: "getAllRelations", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + hasSnapshot: !!settings, + relationDefinitionCount, + relationCount, + resolveGlobalSettingsMs, + flattenRelationsMs, + }), + }, + () => { + const [globalSettings, measuredResolveGlobalSettingsMs] = + measurePerformanceStep(() => + settings + ? settings.globalSettings + : getGlobalSettings({ traceId, source, content }), + ); + resolveGlobalSettingsMs = measuredResolveGlobalSettingsMs; + relationDefinitionCount = Object.keys(globalSettings.Relations).length; + + const [relations, measuredFlattenRelationsMs] = measurePerformanceStep( + () => + Object.entries(globalSettings.Relations).flatMap(([id, relation]) => + relation.ifConditions.map((ifCondition) => ({ + id, + label: relation.label, + source: relation.source, + destination: relation.destination, + complement: relation.complement, + triples: ifCondition.triples, + })), + ), + ); + flattenRelationsMs = measuredFlattenRelationsMs; + relationCount = relations.length; + return relations; + }, ); }; @@ -866,53 +994,146 @@ export type SettingsSnapshot = { 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; +export const bulkReadSettings = ( + trace?: PerformanceTraceArg, +): SettingsSnapshot => { + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["bulkReadSettings"], + }); + let childCount = 0; + let hasFeatureFlagsProps = false; + let hasGlobalProps = false; + let hasPersonalProps = false; + let newStoreEnabled = false; + let pullSettingsPageMs = 0; + let normalizeTopLevelPropsMs = 0; + let parseFeatureFlagsMs = 0; + let parseGlobalSettingsMs = 0; + let parsePersonalSettingsMs = 0; + let legacyFallbackMs = 0; - 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 === STATIC_TOP_LEVEL_ENTRIES.featureFlags.key) { - featureFlagsProps = blockProps; - } else if (text === STATIC_TOP_LEVEL_ENTRIES.global.key) { - globalProps = blockProps; - } else if (text === personalKey) { - personalProps = blockProps; - } - } + return withPerformanceTrace( + { + label: "bulkReadSettings", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + childCount, + hasFeatureFlagsProps, + hasGlobalProps, + hasPersonalProps, + newStoreEnabled, + pullSettingsPageMs, + normalizeTopLevelPropsMs, + parseFeatureFlagsMs, + parseGlobalSettingsMs, + parsePersonalSettingsMs, + legacyFallbackMs, + }), + }, + () => { + const [pageResult, measuredPullSettingsPageMs] = measurePerformanceStep( + () => + window.roamAlphaAPI.pull( + "[{:block/children [:block/string :block/props]}]", + [":node/title", DG_BLOCK_PROP_SETTINGS_PAGE_TITLE], + ) as Record | null, + ); + pullSettingsPageMs = measuredPullSettingsPageMs; + + const children = (pageResult?.[":block/children"] ?? []) as Record< + string, + json + >[]; + childCount = children.length; + const personalKey = getPersonalSettingsKey(); + + const [topLevelProps, measuredNormalizeTopLevelPropsMs] = + measurePerformanceStep(() => { + 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 === STATIC_TOP_LEVEL_ENTRIES.featureFlags.key) { + featureFlagsProps = blockProps; + } else if (text === STATIC_TOP_LEVEL_ENTRIES.global.key) { + globalProps = blockProps; + } else if (text === personalKey) { + personalProps = blockProps; + } + } - const featureFlags = FeatureFlagsSchema.parse(featureFlagsProps || {}); + return { + featureFlagsProps, + globalProps, + personalProps, + }; + }); + normalizeTopLevelPropsMs = measuredNormalizeTopLevelPropsMs; + hasFeatureFlagsProps = + Object.keys( + (topLevelProps.featureFlagsProps || {}) as Record, + ).length > 0; + hasGlobalProps = + Object.keys((topLevelProps.globalProps || {}) as Record) + .length > 0; + hasPersonalProps = + Object.keys((topLevelProps.personalProps || {}) as Record) + .length > 0; + + const [featureFlags, measuredParseFeatureFlagsMs] = + measurePerformanceStep(() => + FeatureFlagsSchema.parse(topLevelProps.featureFlagsProps || {}), + ); + parseFeatureFlagsMs = measuredParseFeatureFlagsMs; + newStoreEnabled = featureFlags["Use new settings store"]; + + if (!newStoreEnabled) { + const [legacySettings, measuredLegacyFallbackMs] = + measurePerformanceStep(() => ({ + globalSettings: readAllLegacyGlobalSettings() as GlobalSettings, + personalSettings: + readAllLegacyPersonalSettings() as PersonalSettings, + })); + legacyFallbackMs = measuredLegacyFallbackMs; + + return { + featureFlags, + ...legacySettings, + }; + } - if (!featureFlags["Use new settings store"]) { - return { - featureFlags, - globalSettings: readAllLegacyGlobalSettings() as GlobalSettings, - personalSettings: readAllLegacyPersonalSettings() as PersonalSettings, - }; - } + const [globalSettings, measuredParseGlobalSettingsMs] = + measurePerformanceStep(() => + GlobalSettingsSchema.parse(topLevelProps.globalProps || {}), + ); + parseGlobalSettingsMs = measuredParseGlobalSettingsMs; - return { - featureFlags, - globalSettings: GlobalSettingsSchema.parse(globalProps || {}), - personalSettings: PersonalSettingsSchema.parse(personalProps || {}), - }; + const [personalSettings, measuredParsePersonalSettingsMs] = + measurePerformanceStep(() => + PersonalSettingsSchema.parse(topLevelProps.personalProps || {}), + ); + parsePersonalSettingsMs = measuredParsePersonalSettingsMs; + + return { + featureFlags, + globalSettings, + personalSettings, + }; + }, + ); }; export const setPersonalSetting = (keys: string[], value: json): void => { @@ -1152,7 +1373,13 @@ const migrateNodeBlockProps = ( return migrated; }; -export const getAllDiscourseNodes = (): DiscourseNode[] => { +export const getAllDiscourseNodes = ( + trace?: PerformanceTraceArg, +): DiscourseNode[] => { + const { traceId, source, content } = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getAllDiscourseNodes"], + }); let rawResultCount = 0; let nodeCount = 0; let skippedCount = 0; @@ -1163,9 +1390,12 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { return withPerformanceTrace( { label: "getAllDiscourseNodes", - thresholdMs: 8, + thresholdMs: 0, aggregateThresholdMs: 50, details: () => ({ + traceId, + source, + content, rawResultCount, nodeCount, skippedCount, diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 72cef618c..b9d53a187 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -54,7 +54,10 @@ export default runExtension(async (onloadArgs) => { refreshConfigTree(); - const settings = bulkReadSettings(); + const settings = bulkReadSettings({ + source: "runExtension:onload:initialSettings", + content: "extension load", + }); if (!settings.personalSettings[PERSONAL_KEYS.disableProductDiagnostics]) { initPostHog(); diff --git a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts index 800f9f234..17ab6335b 100644 --- a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts +++ b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts @@ -46,8 +46,12 @@ const deriveNodeAttribute = async ({ attribute: string; uid: string; }): Promise => { - const relations = getDiscourseRelations(); - const nodes = getDiscourseNodes(relations); + const trace = { + source: "deriveDiscourseNodeAttribute", + content: `uid:${uid} attribute:${attribute}`, + }; + const relations = getDiscourseRelations(undefined, trace); + const nodes = getDiscourseNodes(relations, undefined, trace); const discourseNode = findDiscourseNode({ uid, nodes }); if (!discourseNode) return 0; const nodeType = discourseNode.type; diff --git a/apps/roam/src/utils/findDiscourseNode.ts b/apps/roam/src/utils/findDiscourseNode.ts index 0c3e5ced3..aab59b2ba 100644 --- a/apps/roam/src/utils/findDiscourseNode.ts +++ b/apps/roam/src/utils/findDiscourseNode.ts @@ -2,6 +2,7 @@ import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import matchDiscourseNode from "./matchDiscourseNode"; import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; import { getDiscourseNodeTypeCacheVersion } from "./discourseNodeTypeCache"; +import type { PerformanceTraceArg } from "./performanceLogger"; let discourseNodeTypeCache: Record = {}; let discourseNodeTypeCacheVersion = -1; @@ -11,11 +12,13 @@ const findDiscourseNode = ({ title, nodes, snapshot, + trace, }: { uid: string; title?: string; nodes?: DiscourseNode[]; snapshot?: SettingsSnapshot; + trace?: PerformanceTraceArg; }): DiscourseNode | false => { const currentCacheVersion = getDiscourseNodeTypeCacheVersion(); if (discourseNodeTypeCacheVersion !== currentCacheVersion) { @@ -27,7 +30,7 @@ const findDiscourseNode = ({ return discourseNodeTypeCache[uid]; } - const resolvedNodes = nodes ?? getDiscourseNodes(undefined, snapshot); + const resolvedNodes = nodes ?? getDiscourseNodes(undefined, snapshot, trace); const matchingNode = resolvedNodes.find((node) => title === undefined diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 179c7ee2b..a918789f6 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -171,8 +171,14 @@ const buildQueryConfig = ({ const getDiscourseContextResults = async ({ uid: targetUid, - relations = getDiscourseRelations(), - nodes = getDiscourseNodes(relations), + relations = getDiscourseRelations(undefined, { + source: "getDiscourseContextResults:defaultRelations", + content: `uid:${targetUid}`, + }), + nodes = getDiscourseNodes(relations, undefined, { + source: "getDiscourseContextResults:defaultNodes", + content: `uid:${targetUid}`, + }), ignoreCache, onResult, }: { diff --git a/apps/roam/src/utils/getDiscourseNodes.ts b/apps/roam/src/utils/getDiscourseNodes.ts index e96411284..9ddea381c 100644 --- a/apps/roam/src/utils/getDiscourseNodes.ts +++ b/apps/roam/src/utils/getDiscourseNodes.ts @@ -10,7 +10,12 @@ import getDiscourseRelations from "./getDiscourseRelations"; import { roamNodeToCondition } from "./parseQuery"; import { Condition } from "./types"; import { InputTextNode, RoamBasicNode } from "roamjs-components/types"; -import { withPerformanceTrace } from "./performanceLogger"; +import { + measurePerformanceStep, + resolvePerformanceTraceContext, + withPerformanceTrace, + type PerformanceTraceArg, +} from "./performanceLogger"; export const excludeDefaultNodes = (node: DiscourseNode) => { return node.backedBy !== "default"; @@ -108,30 +113,21 @@ const getUidAndBooleanSetting = ({ }; }; -const getPerformanceNow = (): number => { - if ( - typeof performance !== "undefined" && - typeof performance.now === "function" - ) { - return performance.now(); - } - - return Date.now(); -}; - -const roundDurationMs = (durationMs: number): number => - Math.round(durationMs * 10) / 10; - -const measureStep = (fn: () => T): [T, number] => { - const start = getPerformanceNow(); - const result = fn(); - return [result, roundDurationMs(getPerformanceNow() - start)]; -}; +let getDiscourseNodesCallCount = 0; const getDiscourseNodes = ( relations?: ReturnType, snapshot?: SettingsSnapshot, + trace?: PerformanceTraceArg, ): DiscourseNode[] => { + const context = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getDiscourseNodes"], + }); + const traceId = + context.traceId ?? `getDiscourseNodes#${++getDiscourseNodesCallCount}`; + const source = context.source; + const content = context.content; let relationCount = 0; let configuredNodeCount = 0; let relationNodeCount = 0; @@ -148,11 +144,14 @@ const getDiscourseNodes = ( return withPerformanceTrace( { label: "getDiscourseNodes", - thresholdMs: 8, + thresholdMs: 0, aggregateThresholdMs: 50, details: () => ({ hasRelationsArg: !!relations, hasSnapshot: !!snapshot, + traceId, + source, + content, newStoreEnabled, relationCount, configuredNodeCount, @@ -168,24 +167,27 @@ const getDiscourseNodes = ( }), }, () => { - const [resolvedRelations, measuredResolveRelationsMs] = measureStep( - () => relations ?? getDiscourseRelations(snapshot), - ); + const [resolvedRelations, measuredResolveRelationsMs] = + measurePerformanceStep( + () => + relations ?? + getDiscourseRelations(snapshot, { traceId, source, content }), + ); resolveRelationsMs = measuredResolveRelationsMs; relationCount = resolvedRelations.length; - const [resolvedNewStoreEnabled, measuredResolveStoreFlagMs] = measureStep( - () => + const [resolvedNewStoreEnabled, measuredResolveStoreFlagMs] = + measurePerformanceStep(() => snapshot ? snapshot.featureFlags["Use new settings store"] - : isNewSettingsStoreEnabled(), - ); + : isNewSettingsStoreEnabled({ traceId, source, content }), + ); newStoreEnabled = resolvedNewStoreEnabled; resolveStoreFlagMs = measuredResolveStoreFlagMs; - const [configuredBaseNodes, measuredConfiguredBaseNodesMs] = measureStep( - () => { + const [configuredBaseNodes, measuredConfiguredBaseNodesMs] = + measurePerformanceStep(() => { if (newStoreEnabled) { - return getAllDiscourseNodes(); + return getAllDiscourseNodes({ traceId, source, content }); } return Object.entries(discourseConfigRef.nodes).map( @@ -236,54 +238,59 @@ const getDiscourseNodes = ( }; }, ); - }, - ); + }); configuredBaseNodesMs = measuredConfiguredBaseNodesMs; configuredNodeCount = configuredBaseNodes.length; - const [relationNodes, measuredRelationNodesMs] = measureStep(() => { - return resolvedRelations - .filter((r) => - r.triples.some((t) => t.some((n) => /anchor/i.test(n))), - ) - .map( - (r): DiscourseNode => ({ - format: "", - text: r.label, - type: r.id, - shortcut: r.label.slice(0, 1), - tag: "", - specification: r.triples.map(([source, relation, target]) => ({ - type: "clause", - source: /anchor/i.test(source) ? r.label : source, - relation, - target: - target === "source" - ? r.source - : target === "destination" - ? r.destination - : /anchor/i.test(target) - ? r.label - : target, - uid: window.roamAlphaAPI.util.generateUID(), - })), - backedBy: "relation", - canvasSettings: {}, - }), - ); - }); + const [relationNodes, measuredRelationNodesMs] = measurePerformanceStep( + () => { + return resolvedRelations + .filter((r) => + r.triples.some((t) => t.some((n) => /anchor/i.test(n))), + ) + .map( + (r): DiscourseNode => ({ + format: "", + text: r.label, + type: r.id, + shortcut: r.label.slice(0, 1), + tag: "", + specification: r.triples.map(([source, relation, target]) => ({ + type: "clause", + source: /anchor/i.test(source) ? r.label : source, + relation, + target: + target === "source" + ? r.source + : target === "destination" + ? r.destination + : /anchor/i.test(target) + ? r.label + : target, + uid: window.roamAlphaAPI.util.generateUID(), + })), + backedBy: "relation", + canvasSettings: {}, + }), + ); + }, + ); relationNodesMs = measuredRelationNodesMs; relationNodeCount = relationNodes.length; const configuredNodes = configuredBaseNodes.concat(relationNodes); - const [defaultNodes, measuredDefaultNodesMs] = measureStep(() => { - const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); - return DEFAULT_NODES.filter((n) => !configuredNodeTexts.has(n.text)); - }); + const [defaultNodes, measuredDefaultNodesMs] = measurePerformanceStep( + () => { + const configuredNodeTexts = new Set( + configuredNodes.map((n) => n.text), + ); + return DEFAULT_NODES.filter((n) => !configuredNodeTexts.has(n.text)); + }, + ); defaultNodesMs = measuredDefaultNodesMs; defaultNodeCount = defaultNodes.length; - const [nodes, measuredConcatNodesMs] = measureStep(() => + const [nodes, measuredConcatNodesMs] = measurePerformanceStep(() => configuredNodes.concat(defaultNodes), ); concatNodesMs = measuredConcatNodesMs; diff --git a/apps/roam/src/utils/getDiscourseRelations.ts b/apps/roam/src/utils/getDiscourseRelations.ts index d7e36cab7..0f77f2bd7 100644 --- a/apps/roam/src/utils/getDiscourseRelations.ts +++ b/apps/roam/src/utils/getDiscourseRelations.ts @@ -12,6 +12,12 @@ import { type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import discourseConfigRef from "./discourseConfigRef"; +import { + measurePerformanceStep, + resolvePerformanceTraceContext, + withPerformanceTrace, + type PerformanceTraceArg, +} from "./performanceLogger"; export type Triple = readonly [string, string, string]; export type DiscourseRelation = { @@ -36,41 +42,117 @@ export const getRelationsNode = (grammarNode = getGrammarNode()) => { return grammarNode?.children.find(matchNodeText("relations")); }; -const getDiscourseRelations = (snapshot?: SettingsSnapshot) => { - const newStoreEnabled = snapshot - ? snapshot.featureFlags["Use new settings store"] - : isNewSettingsStoreEnabled(); - if (newStoreEnabled) { - return getAllRelations(snapshot); - } +let getDiscourseRelationsCallCount = 0; + +const getDiscourseRelations = ( + snapshot?: SettingsSnapshot, + trace?: PerformanceTraceArg, +): DiscourseRelation[] => { + const context = resolvePerformanceTraceContext({ + trace, + ignoredPatterns: ["getDiscourseRelations"], + }); + const traceId = + context.traceId ?? + `getDiscourseRelations#${++getDiscourseRelationsCallCount}`; + const source = context.source; + const content = context.content; + let newStoreEnabled = false; + let relationCount = 0; + let relationConfigCount = 0; + let resolveStoreFlagMs = 0; + let getAllRelationsMs = 0; + let getGrammarNodeMs = 0; + let getRelationsNodeMs = 0; + let resolveLegacyRelationNodesMs = 0; + let flattenLegacyRelationsMs = 0; + + return withPerformanceTrace( + { + label: "getDiscourseRelations", + thresholdMs: 0, + aggregateThresholdMs: 50, + details: () => ({ + traceId, + source, + content, + hasSnapshot: !!snapshot, + newStoreEnabled, + relationConfigCount, + relationCount, + resolveStoreFlagMs, + getAllRelationsMs, + getGrammarNodeMs, + getRelationsNodeMs, + resolveLegacyRelationNodesMs, + flattenLegacyRelationsMs, + }), + }, + () => { + const [resolvedNewStoreEnabled, measuredResolveStoreFlagMs] = + measurePerformanceStep(() => + snapshot + ? snapshot.featureFlags["Use new settings store"] + : isNewSettingsStoreEnabled({ traceId, source, content }), + ); + newStoreEnabled = resolvedNewStoreEnabled; + resolveStoreFlagMs = measuredResolveStoreFlagMs; + + if (newStoreEnabled) { + const [relations, measuredGetAllRelationsMs] = measurePerformanceStep( + () => getAllRelations(snapshot, { traceId, source, content }), + ); + getAllRelationsMs = measuredGetAllRelationsMs; + relationCount = relations.length; + return relations; + } - const grammarNode = getGrammarNode(); - const relationsNode = getRelationsNode(grammarNode); - const relationNodes = relationsNode?.children || DEFAULT_RELATION_VALUES; - const discourseRelations = relationNodes.flatMap( - (r: InputTextNode, i: number) => { - const tree = (r?.children || []) as TextNode[]; - const data = { - id: r.uid || `${r.text}-${i}`, - label: r.text, - source: getSettingValueFromTree({ tree, key: "Source" }), - destination: getSettingValueFromTree({ tree, key: "Destination" }), - complement: getSettingValueFromTree({ tree, key: "Complement" }), - }; - const ifNode = tree.find(matchNodeText("if"))?.children || []; - return ifNode.map((node) => ({ - ...data, - triples: node.children - .filter((t) => !/node positions/i.test(t.text)) - .map((t) => { - const target = t.children[0]?.children?.[0]?.text || ""; - return [t.text, t.children[0]?.text, target] as const; + const [grammarNode, measuredGetGrammarNodeMs] = + measurePerformanceStep(getGrammarNode); + getGrammarNodeMs = measuredGetGrammarNodeMs; + + const [relationsNode, measuredGetRelationsNodeMs] = + measurePerformanceStep(() => getRelationsNode(grammarNode)); + getRelationsNodeMs = measuredGetRelationsNodeMs; + + const [relationNodes, measuredResolveLegacyRelationNodesMs] = + measurePerformanceStep( + () => relationsNode?.children || DEFAULT_RELATION_VALUES, + ); + resolveLegacyRelationNodesMs = measuredResolveLegacyRelationNodesMs; + relationConfigCount = relationNodes.length; + + const [discourseRelations, measuredFlattenLegacyRelationsMs] = + measurePerformanceStep(() => + relationNodes.flatMap((r: InputTextNode, i: number) => { + const tree = (r?.children || []) as TextNode[]; + const data = { + id: r.uid || `${r.text}-${i}`, + label: r.text, + source: getSettingValueFromTree({ tree, key: "Source" }), + destination: getSettingValueFromTree({ + tree, + key: "Destination", + }), + complement: getSettingValueFromTree({ tree, key: "Complement" }), + }; + const ifNode = tree.find(matchNodeText("if"))?.children || []; + return ifNode.map((node) => ({ + ...data, + triples: node.children + .filter((t) => !/node positions/i.test(t.text)) + .map((t) => { + const target = t.children[0]?.children?.[0]?.text || ""; + return [t.text, t.children[0]?.text, target] as const; + }), + })); }), - })); + ); + flattenLegacyRelationsMs = measuredFlattenLegacyRelationsMs; + relationCount = discourseRelations.length; + return discourseRelations; }, ); - - return discourseRelations; }; export default getDiscourseRelations; diff --git a/apps/roam/src/utils/getExportTypes.ts b/apps/roam/src/utils/getExportTypes.ts index cabbe3fee..e639d831d 100644 --- a/apps/roam/src/utils/getExportTypes.ts +++ b/apps/roam/src/utils/getExportTypes.ts @@ -67,8 +67,12 @@ const getExportTypes = ({ exportId, isExportDiscourseGraph, }: getExportTypesProps): ExportTypes => { - const allRelations = getDiscourseRelations(); - const allNodes = getDiscourseNodes(allRelations); + const trace = { + source: "getExportTypes", + content: `exportId:${exportId}`, + }; + const allRelations = getDiscourseRelations(undefined, trace); + const allNodes = getDiscourseNodes(allRelations, undefined, trace); const nodeLabelByType = Object.fromEntries( allNodes.map((a) => [a.type, a.text]), ); diff --git a/apps/roam/src/utils/getRelationData.ts b/apps/roam/src/utils/getRelationData.ts index a0b436c93..d407feb20 100644 --- a/apps/roam/src/utils/getRelationData.ts +++ b/apps/roam/src/utils/getRelationData.ts @@ -67,8 +67,12 @@ export const getRelationDataUtil = async ({ ).then((r) => r.flat()); const getRelationData = async (local?: boolean) => { - const allRelations = getDiscourseRelations(); - const allNodes = getDiscourseNodes(allRelations); + const trace = { + source: "getRelationData", + content: `local:${!!local}`, + }; + const allRelations = getDiscourseRelations(undefined, trace); + const allNodes = getDiscourseNodes(allRelations, undefined, trace); const nodeLabelByType = Object.fromEntries( allNodes.map((a) => [a.type, a.text]), ); diff --git a/apps/roam/src/utils/initializeDiscourseNodes.ts b/apps/roam/src/utils/initializeDiscourseNodes.ts index b018bb415..9d4f8820d 100644 --- a/apps/roam/src/utils/initializeDiscourseNodes.ts +++ b/apps/roam/src/utils/initializeDiscourseNodes.ts @@ -7,9 +7,10 @@ import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; const initializeDiscourseNodes = async ( snapshot: SettingsSnapshot, ): Promise => { - const nodes = getDiscourseNodes(undefined, snapshot).filter( - excludeDefaultNodes, - ); + const nodes = getDiscourseNodes(undefined, snapshot, { + source: "initializeDiscourseNodes", + content: "extension load", + }).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 a067f72af..c4d9e10f2 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -78,6 +78,11 @@ const debounce = (fn: () => void, delay = 250) => { }; }; +const compactTraceContent = (content?: string | null): string | undefined => { + const compacted = content?.replace(/\s+/g, " ").trim(); + return compacted ? compacted.slice(0, 120) : undefined; +}; + const getTitleAndUidFromHeader = (h1: HTMLHeadingElement) => { const titleDisplayContainer = h1.closest(".rm-title-display-container"); const dataUid = titleDisplayContainer?.getAttribute("data-page-uid") || ""; @@ -118,6 +123,7 @@ export const initObservers = ({ let isCanvas = false; let isSidebar = false; let renderedCanvasReferences = false; + let content: string | undefined; withPerformanceTrace( { @@ -131,14 +137,19 @@ export const initObservers = ({ isCanvas, isSidebar, renderedCanvasReferences, + content, }), }, () => { const h1 = e as HTMLHeadingElement; const { title, uid } = getTitleAndUidFromHeader(h1); titleLength = title.length; + content = compactTraceContent(title); - const settings = bulkReadSettings(); + const settings = bulkReadSettings({ + source: "observer:pageTitle", + content, + }); const props = { title, h1, onloadArgs }; @@ -146,12 +157,21 @@ export const initObservers = ({ uid, title, snapshot: settings, + trace: { + source: "observer:pageTitle:findDiscourseNode", + content, + }, }); isDiscourseNode = !!node && node.backedBy !== "default"; if (isDiscourseNode && node) { renderDiscourseContext({ h1, uid }); - if (getFeatureFlag("Duplicate node alert enabled")) { + if ( + getFeatureFlag("Duplicate node alert enabled", { + source: "observer:pageTitle:duplicateAlert", + content, + }) + ) { renderPossibleDuplicates(h1, title, node); } const linkedReferencesDiv = document.querySelector( @@ -186,15 +206,18 @@ export const initObservers = ({ const queryBlockObserver = createButtonObserver({ attribute: "query-block", - render: (b) => - withPerformanceTrace( + render: (b) => { + const content = compactTraceContent(b.textContent); + return withPerformanceTrace( { label: "observer:queryBlock", thresholdMs: 16, aggregateThresholdMs: 50, + details: () => ({ content }), }, () => renderQueryBlock(b, onloadArgs), - ), + ); + }, }); const canvasEmbedObserver = createButtonObserver({ @@ -203,7 +226,7 @@ export const initObservers = ({ }); let batchedTagNodes: DiscourseNode[] | null = null; - const getNodesForTagBatch = (): DiscourseNode[] => { + const getNodesForTagBatch = (content: string): DiscourseNode[] => { if (batchedTagNodes === null) { let nodeCount = 0; batchedTagNodes = withPerformanceTrace( @@ -214,8 +237,12 @@ export const initObservers = ({ details: () => ({ nodeCount }), }, () => { - const settings = bulkReadSettings(); - const nodes = getDiscourseNodes(undefined, settings); + const trace = { + source: "observer:nodeTagPopupButton:getNodesForTagBatch", + content: compactTraceContent(content), + }; + const settings = bulkReadSettings(trace); + const nodes = getDiscourseNodes(undefined, settings, trace); nodeCount = nodes.length; return nodes; }, @@ -233,20 +260,22 @@ export const initObservers = ({ callback: (s: HTMLSpanElement) => { let tagLength = 0; let rendered = false; + let content: string | undefined; withPerformanceTrace( { label: "observer:nodeTagPopupButton", thresholdMs: 8, aggregateThresholdMs: 50, - details: () => ({ tagLength, rendered }), + details: () => ({ tagLength, rendered, content }), }, () => { const tag = s.getAttribute("data-tag"); tagLength = tag?.length ?? 0; if (tag) { const normalizedTag = getCleanTagText(tag); + content = compactTraceContent(normalizedTag); - for (const node of getNodesForTagBatch()) { + for (const node of getNodesForTagBatch(normalizedTag)) { const normalizedNodeTag = node.tag ? getCleanTagText(node.tag) : ""; @@ -315,15 +344,19 @@ export const initObservers = ({ className: "rm-inline-img", callback: (img: HTMLElement) => { let rendered = false; + let content: string | undefined; withPerformanceTrace( { label: "observer:imageMenu", thresholdMs: 8, aggregateThresholdMs: 50, - details: () => ({ rendered }), + details: () => ({ rendered, content }), }, () => { if (img instanceof HTMLImageElement) { + content = compactTraceContent( + img.currentSrc || img.src || img.getAttribute("src"), + ); renderImageToolsMenu(img, onloadArgs.extensionAPI); rendered = true; } @@ -349,6 +382,7 @@ export const initObservers = ({ let matchedConfigPage = false; let matchedNodeType = false; let refreshed = false; + let content: string | undefined; withPerformanceTrace( { label: "listener:hashChange", @@ -359,16 +393,22 @@ export const initObservers = ({ matchedConfigPage, matchedNodeType, refreshed, + content, }), }, () => { const evt = e as HashChangeEvent; - const settings = bulkReadSettings(); + content = compactTraceContent(evt.oldURL); + const trace = { + source: "listener:hashChange", + content, + }; + const settings = bulkReadSettings(trace); // Attempt to refresh config navigating away from config page // doesn't work if they update via sidebar matchedConfigPage = !!configPageUid && evt.oldURL.endsWith(configPageUid); - const nodes = getDiscourseNodes(undefined, settings); + const nodes = getDiscourseNodes(undefined, settings, trace); checkedNodeCount = nodes.length; matchedNodeType = nodes.some(({ type }) => evt.oldURL.endsWith(type)); @@ -417,15 +457,19 @@ export const initObservers = ({ className: "starred-pages-wrapper", callback: (el) => { let isLeftSidebarEnabled = false; + const content = "starred-pages-wrapper"; void withAsyncPerformanceTrace( { label: "observer:leftSidebar", thresholdMs: 16, aggregateThresholdMs: 50, - details: () => ({ isLeftSidebarEnabled }), + details: () => ({ isLeftSidebarEnabled, content }), }, async () => { - const settings = bulkReadSettings(); + const settings = bulkReadSettings({ + source: "observer:leftSidebar", + content, + }); isLeftSidebarEnabled = settings.featureFlags["Enable left sidebar"]; const container = el as HTMLDivElement; if (isLeftSidebarEnabled) { @@ -452,6 +496,10 @@ export const initObservers = ({ textarea, extensionAPI: onloadArgs.extensionAPI, isShift: evt.shiftKey, + trace: { + source: "listener:nodeMenuTrigger", + content: compactTraceContent(textarea.value), + }, }); evt.preventDefault(); evt.stopPropagation(); @@ -546,6 +594,7 @@ export const initObservers = ({ let selectedTextLength = 0; let hasBlockElement = false; let rendered = false; + let content: string | undefined; withPerformanceTrace( { label: "listener:selectionchange", @@ -555,10 +604,13 @@ export const initObservers = ({ selectedTextLength, hasBlockElement, rendered, + content, }), }, () => { - const settings = bulkReadSettings(); + const settings = bulkReadSettings({ + source: "listener:selectionchange", + }); if (!settings.personalSettings[PERSONAL_KEYS.textSelectionPopup]) return; @@ -571,6 +623,7 @@ export const initObservers = ({ const selectedText = selection.toString().trim(); selectedTextLength = selectedText.length; + content = compactTraceContent(selectedText); if (!selectedText) { removeTextSelectionPopup(); diff --git a/apps/roam/src/utils/isDiscourseNode.ts b/apps/roam/src/utils/isDiscourseNode.ts index 52f1ec003..dd4a4bd23 100644 --- a/apps/roam/src/utils/isDiscourseNode.ts +++ b/apps/roam/src/utils/isDiscourseNode.ts @@ -2,7 +2,10 @@ import getDiscourseNodes from "./getDiscourseNodes"; import findDiscourseNode from "./findDiscourseNode"; const isDiscourseNode = (uid: string) => { - const nodes = getDiscourseNodes(); + const nodes = getDiscourseNodes(undefined, undefined, { + source: "isDiscourseNode", + content: `uid:${uid}`, + }); const node = findDiscourseNode({ uid, nodes }); if (!node) return false; return node.backedBy !== "default"; diff --git a/apps/roam/src/utils/pageRefObserverHandlers.ts b/apps/roam/src/utils/pageRefObserverHandlers.ts index 1ce12d56c..85eb59e3c 100644 --- a/apps/roam/src/utils/pageRefObserverHandlers.ts +++ b/apps/roam/src/utils/pageRefObserverHandlers.ts @@ -40,7 +40,10 @@ const queueBatchCacheClear = (): void => { queueMicrotask(clearBatchCache); }; -const getBatchDiscourseNodes = (): DiscourseNode[] => { +const compactTraceContent = (content: string): string => + content.replace(/\s+/g, " ").trim().slice(0, 120); + +const getBatchDiscourseNodes = (content: string): DiscourseNode[] => { if (batchDiscourseNodes) return batchDiscourseNodes; let nodeCount = 0; @@ -52,7 +55,10 @@ const getBatchDiscourseNodes = (): DiscourseNode[] => { details: () => ({ nodeCount }), }, () => { - const nodes = getDiscourseNodes(); + const nodes = getDiscourseNodes(undefined, undefined, { + source: "observer:pageRef:getBatchDiscourseNodes", + content: compactTraceContent(content), + }); nodeCount = nodes.length; return nodes; }, @@ -86,7 +92,7 @@ const getPageRefDiscourseNodeStatus = ( ? findDiscourseNode({ uid, title: tag, - nodes: getBatchDiscourseNodes(), + nodes: getBatchDiscourseNodes(tag), }) : false; isDiscourseNode = !!node && node.backedBy !== "default"; diff --git a/apps/roam/src/utils/performanceLogger.ts b/apps/roam/src/utils/performanceLogger.ts index 720338651..22abb93f9 100644 --- a/apps/roam/src/utils/performanceLogger.ts +++ b/apps/roam/src/utils/performanceLogger.ts @@ -9,6 +9,14 @@ type PerformanceTraceOptions = { details?: PerformanceDetails | (() => PerformanceDetails); }; +export type PerformanceTraceContext = { + traceId?: string; + source?: string; + content?: string; +}; + +export type PerformanceTraceArg = string | PerformanceTraceContext | undefined; + type AggregateSample = { count: number; totalDurationMs: number; @@ -24,11 +32,16 @@ type PerformanceDebugWindow = Window & const SLOW_OPERATION_THRESHOLD_MS = 16; const DEBUG_STORAGE_KEY = "dg:performance-debug"; +const INTERNAL_CALLER_PATTERNS = [ + "getPerformanceCaller", + "resolvePerformanceTraceContext", + "normalizePerformanceTraceArg", +]; const aggregateSamples = new Map(); let aggregateFlushQueued = false; -const getPerformanceNow = (): number => { +export const getPerformanceNow = (): number => { if ( typeof performance !== "undefined" && typeof performance.now === "function" @@ -39,6 +52,54 @@ const getPerformanceNow = (): number => { return Date.now(); }; +export const roundPerformanceDurationMs = (durationMs: number): number => + Math.round(durationMs * 10) / 10; + +export const measurePerformanceStep = (fn: () => T): [T, number] => { + const start = getPerformanceNow(); + const result = fn(); + return [result, roundPerformanceDurationMs(getPerformanceNow() - start)]; +}; + +export const normalizePerformanceTraceArg = ( + trace?: PerformanceTraceArg, +): PerformanceTraceContext => { + if (typeof trace === "string") return { traceId: trace }; + return trace ?? {}; +}; + +export const getPerformanceCaller = ( + ignoredPatterns: string[] = [], +): string | undefined => { + const stack = new Error().stack; + if (!stack) return undefined; + const ignored = INTERNAL_CALLER_PATTERNS.concat(ignoredPatterns); + + return stack + .split("\n") + .slice(2) + .map((line) => line.trim().replace(/^at\s+/, "")) + .find( + (line) => + line.length > 0 && !ignored.some((pattern) => line.includes(pattern)), + ); +}; + +export const resolvePerformanceTraceContext = ({ + trace, + ignoredPatterns, +}: { + trace?: PerformanceTraceArg; + ignoredPatterns: string[]; +}): PerformanceTraceContext => { + const context = normalizePerformanceTraceArg(trace); + return { + traceId: context.traceId, + source: context.source ?? getPerformanceCaller(ignoredPatterns), + content: context.content, + }; +}; + const getPerformanceDebugWindow = (): PerformanceDebugWindow | undefined => { if (typeof window === "undefined") return undefined; return window as PerformanceDebugWindow; @@ -76,7 +137,7 @@ const compactDetails = (details: PerformanceDetails): PerformanceDetails => { }; const formatDuration = (durationMs: number): string => { - return `${Math.round(durationMs * 10) / 10}ms`; + return `${roundPerformanceDurationMs(durationMs)}ms`; }; const formatDetailValue = (value: PerformanceDetail): string => { diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index b9bd53719..765c04d8f 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -89,8 +89,12 @@ const collectVariables = (clauses: DatalogClause[]): Set => const ANY_DISCOURSE_NODE = "Any discourse node"; const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { - const discourseRelations = getDiscourseRelations(snapshot); - const discourseNodes = getDiscourseNodes(discourseRelations, snapshot); + const trace = { + source: "registerDiscourseDatalogTranslators", + content: "extension load", + }; + const discourseRelations = getDiscourseRelations(snapshot, trace); + const discourseNodes = getDiscourseNodes(discourseRelations, snapshot, trace); const isACallback: Parameters< typeof registerDatalogTranslator diff --git a/apps/roam/src/utils/renderImageToolsMenu.tsx b/apps/roam/src/utils/renderImageToolsMenu.tsx index 99a7697e2..62939a4e4 100644 --- a/apps/roam/src/utils/renderImageToolsMenu.tsx +++ b/apps/roam/src/utils/renderImageToolsMenu.tsx @@ -9,11 +9,13 @@ import posthog from "posthog-js"; type ImageToolsMenuProps = { blockUid: string; extensionAPI: OnloadArgs["extensionAPI"]; + traceContent?: string; }; const ImageToolsMenu = ({ blockUid, extensionAPI, + traceContent, }: ImageToolsMenuProps): JSX.Element => { const [menuKey, setMenuKey] = useState(0); @@ -45,6 +47,10 @@ const ImageToolsMenu = ({ extensionAPI={extensionAPI} trigger={trigger} isShift={false} + trace={{ + source: "observer:imageMenu:ImageToolsMenu:NodeMenu", + content: traceContent, + }} />