Skip to content

fix(cursor): make automatic cursor overlay navigation-safe - #43

Merged
shreyaskarnik merged 1 commit into
mainfrom
fix/cursor-overlay-nav-reinjection
Aug 22, 2026
Merged

fix(cursor): make automatic cursor overlay navigation-safe#43
shreyaskarnik merged 1 commit into
mainfrom
fix/cursor-overlay-nav-reinjection

Conversation

@shreyaskarnik

Copy link
Copy Markdown
Owner

Summary

Follow-up to #42 (thanks @OleksandrKucherenko!). The config → CLI → record() → env → runtime wiring in that PR was complete and correct; this fixes three edge cases in the new framenavigated reinjection path.

1. document.body can be null at navigation commit

Playwright emits framenavigated when the navigation commits, which can precede the parser producing <body> — a parser-blocking <script src> or stylesheet in <head> widens that window to hundreds of ms. cursorHighlight() ended in document.body.appendChild(dot), so it threw TypeError: Cannot read properties of null (reading 'appendChild'). That message isn't in cursor.ts's disposal-error allowlist, so it surfaced as Warning: cursor highlight failed: … and the ring was gone for the rest of that page (no further framenavigated to retry).

Injection now defers to DOMContentLoaded when body is absent, guarded by a generation counter so a later call supersedes a still-queued install rather than stacking two rings.

2. Same-document navigations reinjected needlessly

Playwright dispatches navigated from frameCommittedSameDocumentNavigation as well (frames.js:186frameDispatcher.js:57client/frame.js:82), so every pushState/replaceState/hash route change hit the listener even though the document was never replaced. Two consequences:

  • the rebuilt ring resets to left: -100px; top: -100px, so the cursor disappears from the recording after every SPA route change until the next mouse event;
  • cursorHighlight()'s replace path removed the old element but never invoked the __cleanup it stashed on it, leaking a capturing mousemove + click listener on a detached node per route change (N leaks → N stacked ripples per click).

Adds an internal ensureCursorHighlight() that no-ops when a ring is already present or queued. The explicit-replace path now runs the previous cleanup first, so cursorHighlight() called twice in one document no longer leaks either.

3. The paint nudge was chained behind the cursor evaluate

That framenavigated listener exists to unstick CDP promptly after page.goto() lands (heavy SPAs otherwise hold the stale pre-nav JPEG as gap-fill source). Chaining _triggerPaint() onto the cursor injection deferred it by the evaluate round-trip plus the wait for the new document's execution context — lengthening the exact window it was written to shorten. Paint now fires immediately and again once the cursor lands.

Notes

  • ensureCursorHighlight is intentionally not re-exported from index.ts — public API surface is unchanged.
  • Also documents in-code that moving this._recordingPage = page out of the legacy else branch (feat(recording): add automatic cursor overlay #42) means _triggerPaint() from mark() now actually runs on the chromium + jpeg-stitch CDP-direct path, where it was previously a silent no-op. That's a latent bug fixed incidentally by feat(recording): add automatic cursor overlay #42 and worth keeping.

Testing

  • npx tsc --noEmit
  • npx vitest run — 683 passing. The 3 failures (tests/e2e/record.e2e.test.ts, tests/transitions/shader-render.test.ts) reproduce on a clean main and are missing local Playwright browsers, not related to this change.
  • New tests capture the function handed to page.evaluate and execute it against a minimal DOM stub, so the injection logic is covered rather than just its arguments — null-body deferral, supersede-on-requeue, ensure no-op paths, and cleanup-on-replace.
  • Each new test was verified to fail against the pre-fix implementation:
    • removing the body-null deferral → 4 failures
    • removing the __cleanup call on replace → replacing a ring runs the previous cleanup so listeners do not leak
    • re-chaining paint behind the injection → nudges paint after navigation without waiting on the cursor injection

Follow-up to #42. Three issues in the `framenavigated` reinjection path:

1. `document.body` can be null at navigation commit. Playwright emits
   `framenavigated` when the nav commits, which can precede the parser
   producing <body> (a render/parser-blocking resource in <head> widens
   the window). `cursorHighlight()` ended in `document.body.appendChild`,
   throwing `TypeError: Cannot read properties of null` — a message that
   is not in cursor.ts's disposal-error allowlist, so it surfaced as a
   warning and left the page permanently cursor-less. Injection now
   defers to DOMContentLoaded when body is absent, with a generation
   counter so a later call supersedes a queued install.

2. Same-document navigations reinjected needlessly. Playwright dispatches
   `navigated` from `frameCommittedSameDocumentNavigation` too, so every
   pushState/hash route change rebuilt the ring: it reset to left:-100px
   (invisible until the next mouse event) and the replace path removed
   the element without invoking the `__cleanup` stored on it, leaking a
   capturing mousemove + click listener per route change (N leaks => N
   stacked ripples per click). Adds an internal `ensureCursorHighlight()`
   that no-ops when a ring is present or queued; the replace path now
   runs the previous cleanup.

3. The paint nudge was chained behind the cursor evaluate. That listener
   exists to unstick CDP promptly after a navigation, and the evaluate
   blocks on the new document's execution context. Paint now fires
   immediately and again once the cursor lands.

`ensureCursorHighlight` is deliberately not re-exported from index.ts —
public API surface is unchanged.

Tests execute the injected browser function against a DOM stub, so the
injection logic itself is covered rather than just its arguments. Each
new test was verified to fail against the pre-fix implementation.
@shreyaskarnik
shreyaskarnik merged commit adf52e9 into main Aug 22, 2026
4 checks passed
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.

1 participant