Skip to content

Stop stale Bubble objects from reacting after Comical replaces them - #121

Merged
andrew-polk merged 1 commit into
masterfrom
BL-15992-stale-bubble-observers
Sep 17, 2026
Merged

andrew-polk merged 1 commit into
masterfrom
BL-15992-stale-bubble-observers

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Comical.convertBubbleJsonToCanvas registers a new ContainerData for a parent that is already active without stopping the previous generation of Bubble objects from monitoring their content elements. Every time a client turns editing on again for the same canvas (Bloom does so whenever it adds a canvas element), the old bubbles leak and keep running adjustSizeAndPosition on every DOM change under the canvas.

That was mostly invisible until a stale family bubble (a parent or child speech bubble) woke up after its element's level had been renumbered: findRelatives looks the family up in the current bubble list by the bubble's stale cached level, finds nothing, and uniteShapes dereferenced selfAndRelatives[0]:

TypeError: Cannot read properties of undefined (reading 'outline')
    at Bubble.uniteShapes
    at Bubble.adjustSizeAndPosition
    at MutationObserver.<anonymous>

Found while adding a Layer (z-order) submenu to Bloom (BL-15992, BloomBooks/BloomDesktop#8372), which renumbers bubble levels and then calls Comical.update(); it surfaced there as a "A JavaScript error occurred" toast whenever a page had a child bubble.

Fix

  • convertBubbleJsonToCanvas stops monitoring for the container it is about to replace, so old bubbles no longer leak.
  • A bubble's mutation observer checks Comical.isCurrentBubble(this) (new) and lets go of the content if Comical no longer lists that bubble, instead of redrawing from a stale spec.
  • uniteShapes returns when findRelatives comes back empty, as a last line of defence.

Verified by patching the same logic into the Comical module of a running Bloom: the family move that reliably threw before no longer does, and live bubbles still unite and redraw.

🤖 Generated with Claude Code


This change is Reviewable

convertBubbleJsonToCanvas registered a new ContainerData for a parent that
was already active without stopping the previous generation of Bubbles from
monitoring their content elements. Every time a client turned editing on
again for the same canvas (Bloom does this whenever it adds an element), the
old Bubbles leaked and kept running adjustSizeAndPosition on every DOM
change. That was mostly harmless until a stale *family* bubble woke up after
its element's level had been renumbered: findRelatives looks the family up in
the current bubble list by the stale cached level, finds nothing, and
uniteShapes dereferenced selfAndRelatives[0] -> "Cannot read properties of
undefined (reading 'outline')".

- convertBubbleJsonToCanvas now stops monitoring for the container it is
  about to replace.
- A Bubble's mutation observer lets go of the content if Comical no longer
  lists that Bubble (new Comical.isCurrentBubble), instead of redrawing from
  a stale spec.
- uniteShapes returns when findRelatives comes back empty.

Found while adding a Layer (z-order) menu to Bloom (BL-15992), which renumbers
bubble levels and then calls Comical.update().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown

Retrigger

The implementation appears safe to merge, with the non-blocking concern that its observer lifecycle regression is not protected by an automated test.

Summary

This PR prevents replaced Bubble instances from continuing to react to DOM mutations.

  • Disconnects the previous bubble generation before replacing an active container.
  • Makes mutation observers release bubbles no longer present in Comical’s current list.
  • Avoids dereferencing an empty family lookup as a final defensive measure.
  • The behavior is not covered by an automated repeated-conversion regression test.

Reviews (1) · Last reviewed commit: "Stop stale Bubble objects from reacting ..."

@JohnThomson
JohnThomson marked this pull request as ready for review September 17, 2026 21:59

@andrew-polk andrew-polk left a comment

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.

@andrew-polk reviewed 2 files and all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on JohnThomson).

@andrew-polk andrew-polk left a comment

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.

Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on JohnThomson).

@andrew-polk
andrew-polk merged commit 77fd77a into master Sep 17, 2026
1 of 2 checks passed
@andrew-polk
andrew-polk deleted the BL-15992-stale-bubble-observers branch September 17, 2026 22:03
JohnThomson added a commit to BloomBooks/BloomDesktop that referenced this pull request Sep 17, 2026
…row (BL-15992)

Renumbering bubble levels and calling Comical.update() woke stale Bubble
objects that ComicalJS had leaked each time editing was turned back on for a
canvas; a stale family bubble then found no relatives and threw in
uniteShapes, which Bloom showed as a "JavaScript error" toast on any Layer
move on a page with a child bubble. comicaljs 0.4.2 (BloomBooks/comical-js#121)
stops monitoring the replaced bubbles and guards the callback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
JohnThomson added a commit to BloomBooks/BloomDesktop that referenced this pull request Sep 17, 2026
https://issues.bloomlibrary.org/youtrack/issue/BL-15992

Overlapping canvas elements were always stacked in the order they were added,
with no way to change it. This adds a Layer submenu to the canvas element
menu (the "..." button and the right-click menu), in its own group just
before Duplicate and Delete, with Bring Forward (Ctrl+]), Bring to Front
(Ctrl+Alt+]), Send Backwards (Ctrl+[) and Send to Back (Ctrl+Alt+[). The
submenu uses the MUI LayersOutlined icon and the four item icons attached to
the card, drawn as SvgIcon components (LayerIcons.tsx).

Stacking order is the DOM order of the elements in the bloom-canvas, while
ComicalJS draws and hit-tests bubbles by the `level` in each element's
data-bubble spec. The new CanvasElementZOrder module moves the element in the
DOM and then renumbers every level to match (background image first, at
level 1) before calling Comical.update(). A bubble family (parent plus child
bubbles, which share a level) moves as one unit; a family that had become
scattered in the DOM is gathered next to its parent first, so every move is
exactly one step. On game pages elements only move among their own kind
(draggable or fixed), matching the rule that draggables stay on top; a family
with any draggable member counts as draggable. The game code that pushes
draggables to the top shares the level-renumbering helper.

The submenu is hidden for the background image, which must stay behind
everything, and disabled when there is only one movable element; the forward
items are disabled at the top of the stack and the backward items at the
bottom. LocalizableNestedMenuItem now passes `disabled` through so the
submenu row itself can be disabled. The keyboard shortcuts accept either the
physical US bracket keys or the bracket characters (an AltGr-typed bracket
counts as the one-step shortcut), and are left alone when no canvas element
is selected or text is being edited.

comicaljs is updated to 0.4.2 (BloomBooks/comical-js#121): Comical leaked the
previous generation of Bubble objects each time editing was turned back on,
and after a level renumbering a stale family bubble threw in uniteShapes,
which showed as a "JavaScript error" toast on any Layer move on a page with a
child bubble.

Five new strings in Bloom.xlf (EditTab.Toolbox.CanvasTool.Layer.*). Also fixes
stale identifiers in the canvas toolbox README and logs a papercut about
build/agent-vite.ps1 reporting exit code 1 on a successful build.

Tests: CanvasElementZOrder.test.ts (moves, families, level renumbering,
draggable bands), layerMenuItem.test.ts (menu placement, per-type inclusion,
disabled states) and CanvasElementKeyboardProvider.test.ts (shortcuts,
layouts). Full front-end suite green; typecheck and eslint clean; isolated
Vite production build succeeds; verified by hand in a running Bloom.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
JohnThomson added a commit to BloomBooks/BloomDesktop that referenced this pull request Oct 9, 2026
https://issues.bloomlibrary.org/youtrack/issue/BL-15992

Overlapping canvas elements were always stacked in the order they were added,
with no way to change it. This adds a Layer submenu to the canvas element menu
(the "..." button and the right-click menu), in its own group just before
Duplicate and Delete: Bring Forward (Ctrl+]), Bring to Front (Ctrl+Shift+]),
Send Backwards (Ctrl+[) and Send to Back (Ctrl+Shift+[). Every canvas element
type gets it, tables dragged onto a Canvas page included, except the background
image, which must stay behind everything. The submenu is disabled when there is
only one movable element; the forward items are disabled at the top of the stack
and the backward items at the bottom. LocalizableNestedMenuItem now passes
`disabled` through so the submenu row itself can be disabled. The shortcuts
accept either the physical US bracket keys or the bracket characters (an
AltGr-typed bracket counts as the one-step shortcut), and are left alone when
no canvas element is selected or text is being edited.

Stacking order is the DOM order of the elements in the bloom-canvas, while
ComicalJS draws and hit-tests bubbles by the `level` in each element's
data-bubble spec. The new CanvasElementZOrder module moves the element in the
DOM and then renumbers every level to match (background image first, at level
1) before calling Comical.update(). A bubble family (parent plus child bubbles,
which share a level) moves as one unit; a family that had become scattered in
the DOM is gathered next to its parent first, so every move is exactly one
step. On game pages elements only move among their own kind (draggable or
fixed), matching the rule that draggables stay on top; a family with any
draggable member counts as draggable. The game code that pushes draggables to
the top shares the level-renumbering helper.

comicaljs is updated to 0.4.2 (BloomBooks/comical-js#121): Comical leaked the
previous generation of Bubble objects each time editing was turned back on,
and after a level renumbering a stale family bubble threw in uniteShapes,
which showed as a "JavaScript error" toast on any Layer move on a page with a
child bubble.

Five new strings in Bloom.xlf (EditTab.Toolbox.CanvasTool.Layer.*). Also fixes
stale identifiers in the canvas toolbox README and logs a papercut about
build/agent-vite.ps1 reporting exit code 1 on a successful build.

Tests: CanvasElementZOrder.test.ts (moves, families, level renumbering,
draggable bands), layerMenuItem.test.ts (menu placement, per-type inclusion,
disabled states) and CanvasElementKeyboardProvider.test.ts (shortcuts,
layouts). The e2e test src/BloomE2E/tests/canvas-layer-order.spec.ts (Notion
test case 840) drives it in a real Bloom: the menu's enabled states, every
command from the menu and from its shortcut with Comical's levels following
the DOM order, a click on two overlapping bubbles selecting the front one, the
order surviving a page change, and a bubble family moving as one with no
script error. Its helpers are helpers/canvasLayers.ts and helpers/scriptErrors.ts,
plus submenu support and Add Child Bubble in helpers/canvasElements.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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