What happens
- A folder
mockups/canvases/v1.15.0/ with boards in it; the page menu shows
v1.15.0.
- Rename the folder to
v1.16 (or delete it outright).
- Restart the dev server, so the scanner picks the change up, and reload.
The page menu now lists v1.16 and v1.15.0. The old page is still there,
still carrying every board shape it had, and reloading does not clear it — nor
does restarting the server, nor opening the canvas in a fresh tab. Since a
board shape is a live <iframe srcDoc>, the dead page also keeps rendering
boards for files that are no longer on disk.
A page is tied to its folder by meta.canvasSlug, so renaming a folder renames
the slug: the new folder gets a new page and the old page is orphaned.
layout.json's name is the documented way to rename a page without that, but
renaming the folder is a natural thing to reach for, and deleting a folder has
no such escape hatch at all.
The one thing that does clear it is the refresh button in the top bar — and
only when nothing else is on the page. See the second half below.
Why
pruneEmptyOrphanPages (canvas/src/App.tsx:708) is the only thing that
removes an orphan page, and it skips any page that has a shape on it:
function pruneEmptyOrphanPages(editor: Editor, libraryPages: Set<TLPageId>) {
for (const page of editor.getPages()) {
if (libraryPages.has(page.id)) continue;
if (editor.getPageShapeIds(page.id).size) continue; // ← never 0 on a former board page
if (editor.getPages().length > 1) editor.deletePage(page.id);
}
}
The shapes keeping the orphan alive are the boards the library itself placed.
On a normal load nothing reclaims them: initializeCanvasLibrary only walks
the folders it just read, and lockLibraryShapes only re-locks. So the guard
that exists to protect a page someone drew on is instead being tripped by the
plugin's own output, and the page survives every reload.
relayoutCanvasLibrary — the refresh button — is the exception, because it
walks editor.getPages() and clears library shapes everywhere before
re-initialising. That is why refresh fixes it and reload never does. Nothing
tells the reader that, and the page reappears looking permanent in between.
The second half: an invisible shape makes it permanent
Even refresh does not help if the orphan holds one non-library shape, and the
easiest way to get one is to not notice you made it. Clicking the text tool on
the canvas and then clicking away leaves a text shape behind with
props.w: 8 and richText: { type: "doc", content: [{ type: "paragraph" }] }
— it renders nothing and there is no way to tell that page from an empty one by
looking at it. That is what happened here: after the library shapes were
reclaimed, the page was kept alive for good by
shape:5yyePpXTT4iGYi3rJ48aH type: "text" w: 8 plain text: ""
pruneEmptyOrphanPages counts it as "someone drew here", so the page stays in
the menu forever over a shape nobody knew they had made. The only way out is
tldraw's own Delete page, which is not an obvious move given every other
page in that menu is managed by the plugin.
Suggested fix
Two small changes in App.tsx, both inside pruneEmptyOrphanPages.
Reclaim the library's own shapes on an orphan page before deciding whether the
page is empty, so the guard is judging hand-drawn content rather than plugin
output:
function pruneEmptyOrphanPages(editor: Editor, libraryPages: Set<TLPageId>) {
for (const page of editor.getPages()) {
if (libraryPages.has(page.id)) continue;
// The boards are gone with the folder, so the shapes the library put here are all that is
// keeping the page alive — and nothing else ever reclaims them, because every other sweep
// walks the pages the library just filled. Without this, renaming or deleting a folder
// leaves a page of dead boards behind for good.
deleteLibraryShapes(
editor,
[...editor.getPageShapeIds(page.id)].filter(isLibraryShapeId),
);
if (drawnOn(editor, page.id)) continue;
if (editor.getPages().length > 1) editor.deletePage(page.id);
}
}
And judge "drawn on" by what actually renders, so an empty text shape does not
outrank a folder that no longer exists:
/**
* Whether anything a person would actually see is left on a page.
*
* Text shapes with no text do not count. Clicking the text tool on the canvas and then clicking
* away leaves one behind — zero-width, nothing rendered — and the page it sits on cannot be told
* from an empty one by looking at it. Counting those as content kept a folder's page in the menu
* forever over a shape nobody knew they had made.
*/
function drawnOn(editor: Editor, pageId: TLPageId) {
return [...editor.getPageShapeIds(pageId)].some((id) => {
const shape = editor.getShape(id);
if (shape?.type !== "text") return true;
const { richText } = (shape as TLTextShape).props;
return renderPlaintextFromRichText(editor, richText).trim() !== "";
});
}
(renderPlaintextFromRichText comes from tldraw.) A page with a real
annotation on it still survives its folder, which is the behaviour the existing
guard is there for.
Verified against a local copy: red before, green after, on both a small
two-page repro and a faithful 70-board one, plus a guard that a page with an
actual drawing on it is not deleted.
Environment
Reproduces on main @ 43da34d, and on the published plugin at
super-prototyping/1.0.0.
What happens
mockups/canvases/v1.15.0/with boards in it; the page menu showsv1.15.0.v1.16(or delete it outright).The page menu now lists
v1.16andv1.15.0. The old page is still there,still carrying every board shape it had, and reloading does not clear it — nor
does restarting the server, nor opening the canvas in a fresh tab. Since a
board shape is a live
<iframe srcDoc>, the dead page also keeps renderingboards for files that are no longer on disk.
A page is tied to its folder by
meta.canvasSlug, so renaming a folder renamesthe slug: the new folder gets a new page and the old page is orphaned.
layout.json'snameis the documented way to rename a page without that, butrenaming the folder is a natural thing to reach for, and deleting a folder has
no such escape hatch at all.
The one thing that does clear it is the refresh button in the top bar — and
only when nothing else is on the page. See the second half below.
Why
pruneEmptyOrphanPages(canvas/src/App.tsx:708) is the only thing thatremoves an orphan page, and it skips any page that has a shape on it:
The shapes keeping the orphan alive are the boards the library itself placed.
On a normal load nothing reclaims them:
initializeCanvasLibraryonly walksthe folders it just read, and
lockLibraryShapesonly re-locks. So the guardthat exists to protect a page someone drew on is instead being tripped by the
plugin's own output, and the page survives every reload.
relayoutCanvasLibrary— the refresh button — is the exception, because itwalks
editor.getPages()and clears library shapes everywhere beforere-initialising. That is why refresh fixes it and reload never does. Nothing
tells the reader that, and the page reappears looking permanent in between.
The second half: an invisible shape makes it permanent
Even refresh does not help if the orphan holds one non-library shape, and the
easiest way to get one is to not notice you made it. Clicking the text tool on
the canvas and then clicking away leaves a text shape behind with
props.w: 8andrichText: { type: "doc", content: [{ type: "paragraph" }] }— it renders nothing and there is no way to tell that page from an empty one by
looking at it. That is what happened here: after the library shapes were
reclaimed, the page was kept alive for good by
pruneEmptyOrphanPagescounts it as "someone drew here", so the page stays inthe menu forever over a shape nobody knew they had made. The only way out is
tldraw's own Delete page, which is not an obvious move given every other
page in that menu is managed by the plugin.
Suggested fix
Two small changes in
App.tsx, both insidepruneEmptyOrphanPages.Reclaim the library's own shapes on an orphan page before deciding whether the
page is empty, so the guard is judging hand-drawn content rather than plugin
output:
And judge "drawn on" by what actually renders, so an empty text shape does not
outrank a folder that no longer exists:
(
renderPlaintextFromRichTextcomes fromtldraw.) A page with a realannotation on it still survives its folder, which is the behaviour the existing
guard is there for.
Verified against a local copy: red before, green after, on both a small
two-page repro and a faithful 70-board one, plus a guard that a page with an
actual drawing on it is not deleted.
Environment
Reproduces on
main@43da34d, and on the published plugin atsuper-prototyping/1.0.0.