Skip to content

[ENG-1287] On canvas load, update key image#938

Open
trangdoan982 wants to merge 3 commits intomainfrom
eng-1287-on-canvas-load-update-key-image
Open

[ENG-1287] On canvas load, update key image#938
trangdoan982 wants to merge 3 commits intomainfrom
eng-1287-on-canvas-load-update-key-image

Conversation

@trangdoan982
Copy link
Copy Markdown
Member

@trangdoan982 trangdoan982 commented Apr 2, 2026

@linear
Copy link
Copy Markdown

linear bot commented Apr 2, 2026

@supabase
Copy link
Copy Markdown

supabase bot commented Apr 2, 2026

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

@trangdoan982 trangdoan982 requested a review from mdroidian April 2, 2026 22:50
@trangdoan982 trangdoan982 requested a review from mdroidian April 6, 2026 17:30
devin-ai-integration[bot]

This comment was marked as resolved.

Comment on lines +79 to +85
const getNodeCanvasSettings = (nodeType: string): Record<string, string> => {
const allNodes = getDiscourseNodes();
const canvasSettings = Object.fromEntries(
allNodes.map((n) => [n.type, { ...n.canvasSettings }]),
);
return canvasSettings[nodeType] || {};
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Repeated getDiscourseNodes() calls inside getNodeCanvasSettings

getNodeCanvasSettings (calcCanvasNodeSizeAndImg.ts:79-85) calls getDiscourseNodes() on every invocation, which reads from the Roam database. In syncCanvasKeyImagesOnLoad, getCanvasNodeKeyImageUrl is called once per surviving shape in the first pass (via Promise.all), and once again per changed shape in the second pass (via calcCanvasNodeSizeAndImg). Each call triggers a full getDiscourseNodes(). For canvases with many shapes, this could be noticeably slow. Consider caching the canvas settings for the duration of the sync operation, or passing them as a parameter.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +180 to +195
// Second pass: load images only for shapes whose URL changed, to compute new dimensions.
await Promise.all(
changedShapes.map(async ({ shape, title }) => {
const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: title,
uid: shape.props.uid,
nodeType: shape.type,
extensionAPI,
});
imageUpdates.push({
id: shape.id,
type: shape.type,
props: { imageUrl, w, h },
});
}),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Redundant getCanvasNodeKeyImageUrl call in two-pass image sync

The two-pass design in syncCanvasKeyImagesOnLoad calls getCanvasNodeKeyImageUrl in the first pass (line 166) to detect changes, then calls calcCanvasNodeSizeAndImg in the second pass (line 183) which internally calls getCanvasNodeKeyImageUrl again (calcCanvasNodeSizeAndImg.ts:143). For shapes using the query-builder key-image option, this means runQuery is executed twice per changed shape. This is inefficient but not a correctness bug — the second call will produce the same result in all realistic scenarios, and the final state is always consistent. A possible optimization would be to pass the already-fetched imageUrl from the first pass into the second pass and only compute dimensions, avoiding the redundant URL resolution.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

@mdroidian mdroidian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Functionally this direction makes sense, but the main risk is how much Roam and the browser do on canvas open. With 20+ discourse nodes that use key images, the current flow can trigger a large number of independent resolutions (tree walks, query-builder runs, pulls, and image loads), not a small fixed cost. The highest-impact follow-ups here should be performance-focused before we lean on this in real graphs.

Why this matters

  • syncCanvasKeyImagesOnLoad runs getCanvasNodeKeyImageUrl for every surviving node in parallel (Promise.all over all shapes). Each call can be quite heavy.
  • For nodes whose stored URL differs from the resolved URL, calcCanvasNodeSizeAndImg calls getCanvasNodeKeyImageUrl again, so changed nodes pay for URL resolution twice before dimensions are computed. That’s an easy on the hottest path when many URLs update at once (e.g. after settings or data changes).
  • The second pass also loadImages in parallel for every changed shape. That can be fine for a few images but rough on large canvases (many concurrent network decodes, main-thread work).

So the concern isn’t only “many shapes” — it’s many shapes × (possibly expensive per-node Roam work + duplicate resolution + parallel image loads).

Testing / acceptance (please block merge on this)

After the performance work lands, verify on a large canvas, not a 3-node fixture:

  1. Prefer an existing heavy canvas in graph plugin-testing-akamatsulab2: open a page known to host a big tldraw canvas. Aim for ≥20 discourse node shapes with distinct key images if possible.
  2. If no suitable page exists, create a test page in that graph with 25+ nodes, each with a different key image (exercises URL resolution + image load paths).
  3. Report concrete signals in the PR : time-to-interactive or wall time until sync finishes, any UI jank, and whether Roam feels responsive. A before/after comparison on the same canvas is ideal.

};

/**
* On canvas load: sync discourse node shape titles with Roam and remove shapes whose nodes no longer exist.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to update the comment to include images

const urlResults = await Promise.all(
survivingShapes.map(async (shape) => {
const title = uidToTitle.get(shape.props.uid) ?? shape.props.title ?? "";
const imageUrl = await getCanvasNodeKeyImageUrl({
Copy link
Copy Markdown
Member

@mdroidian mdroidian Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each shape this is running getNodeCanvasSettings > getDiscourseNodes() > getDiscourseRelations()

Let's run getDiscourseNodes() once and get the canvas setting directly.

uid: shape.props.uid,
nodeType: shape.type,
extensionAPI,
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also running an individual query for each shape. This could be hundreds of queries.

We'll have to write a custom query for any nodes that have the key-image option of First image on page. So we can run one single query rather than a query for each node. Those with Query builder reference will probably still be a Promise.all, unfortunately.

// Second pass: load images only for shapes whose URL changed, to compute new dimensions.
await Promise.all(
changedShapes.map(async ({ shape, title }) => {
const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we only run this when

  • imageUrl was "" and is now not empty
  • imageUrl was not empty and is now ""

This way we only need to load the images that we need to measure.

We should leave the w/h if just the image changes. If the image dimensions change, display should have mostly already been taken care of with ENG-1334: port over the key image resizing behavior from Obsidian

await Promise.all(
changedShapes.map(async ({ shape, title }) => {
const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: title,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calcCanvasNodeSizeAndImg is running the query for imageUrl a second time here. So for 100 shapes it would be 200 queries (if each shape changed).

As stated in the comment above, let's only run calcCanvasNodeSizeAndImage when we need to set a new w, h.
And let's make sure we lass the imageUrl so we don't need to requery it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants