Skip to content

Renaming or deleting a canvases folder leaves its page, and its boards, in the page menu #43

Description

@Jing-yilin

What happens

  1. A folder mockups/canvases/v1.15.0/ with boards in it; the page menu shows
    v1.15.0.
  2. Rename the folder to v1.16 (or delete it outright).
  3. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions