Skip to content

perf(splittext): detect a lost split with a sentinel element instead of a MutationObserver - #387

Closed
ameerf-wix wants to merge 1 commit into
plugin-refreshfrom
plugin-refresh-sentinel
Closed

ameerf-wix wants to merge 1 commit into
plugin-refreshfrom
plugin-refresh-sentinel

Conversation

@ameerf-wix

Copy link
Copy Markdown
Contributor

Stacked on #386, and targets its branch. A prototype for comparison, not ready to merge.

Why

#386 detects a replaced split container with a MutationObserver that watches childList on each ancestor from the container's parent up to the root. This PR tries a way that needs no observer at all, and also catches one more case.

Change

  • The plugin appends a hidden custom element, <splittext-sentinel hidden aria-hidden="true">, to each split container.
  • Why it works: the browser calls its disconnectedCallback whenever it leaves the document. That happens when the container is removed, when any ancestor between it and the root is removed, or when the container's children are replaced. Nothing runs until one of those happens.
  • Re-splits: autoSplit re-splits rebuild the container and remove the sentinel. The plugin wraps onSplit to put a new sentinel back after every split. It keeps calling a caller's own onSplit.
  • Moves: moving the container disconnects the sentinel and connects it again right away. So the check waits a microtask and refreshes only if the container or its current sentinel is still out of the document.
  • One refresh per plugin run: the first lost split stops every watcher of that plugin application and calls refresh() once.
  • Cleanup: it stops the watchers before it reverts, so the revert doesn't trigger a refresh.
  • Shared definition: customElements.get() is checked first, so every copy of the plugin on a page shares one definition. Each copy only sets onDisconnect on its own sentinels.
  • No custom elements (or no refresh): the plugin splits without watching.

Trade-offs

  • More coverage: this also refreshes when a re-render keeps the container and replaces its children, which feat(interact): let a plugin refresh when its output is replaced #386 misses.
  • An extra node in each split container: it comes after [data-splittext-wrapper], so selectors such as :last-child or :empty on the container's children see it. The README says so.
  • A global custom-element name: splittext-sentinel.

Benchmark

Chromium, 400 split containers, each three levels below the root. Medians of 7 runs, in ms; the spread between three separate runs was about ±3 ms.

Workload No detection Root subtree observer (#386's first commit) Ancestor observer (#386) Sentinel (this PR)
Split all containers 64.8–65.8 64.3–66.3 64.7–72.2 64.3–74.2
20 × 400 add/remove next to every host (an ancestor's children) 95.6–96.7 98.5–100.1 97.0–98.7 94.7–96.5
20 × 400 add/remove elsewhere in the root 95.2–95.3 99.8–106.1 95.1–96.5 95.3–95.5
Replace every host, as a hydration would 13.0–13.7 12.1–13.1 12.5–13.8 10.8–13.3

The root subtree observer is measurably slower on unrelated churn. The ancestor observer and the sentinel are both within noise of no detection. So performance alone doesn't decide between them; the coverage and the extra node do.

Verification

  • splittext: 154 tests pass. tsc passes for splittext and the demo app, and eslint and prettier --check pass repo-wide.
  • New tests:
    • It refreshes when a re-render replaces the container's children but keeps the container.
    • It doesn't refresh when the container moves, or when autoSplit re-splits; onSplit still runs for each split.
    • Several replaced containers lead to one refresh.
    • It uses one hidden sentinel per container and no MutationObserver.
    • The cleanup removes the sentinel.
  • Against feat(interact): let a plugin refresh when its output is replaced #386's ancestor observer, the tests for replaced children and for "no MutationObserver" fail.
  • Browser: two production server-rendered pages with split titles that a React 18 hydration replaces, running this branch's build. All five split titles split again after hydration and their sequences play: the same results as feat(interact): let a plugin refresh when its output is replaced #386.

🤖 Generated with Claude Code

…utationObserver

The plugin appends a hidden `<splittext-sentinel>` custom element to each
split container. The browser calls its `disconnectedCallback` when it leaves
the document, whether the container or an ancestor was removed or the
container's children were replaced, so nothing runs until then.

It also leaves on the plugin's own `autoSplit` re-splits, which the plugin
follows through `onSplit` with a new sentinel, and on moves. So the check
waits a microtask, and only a split still out of the document refreshes,
once for all of the plugin's containers. The cleanup stops the watchers
before it reverts.

Also covers a re-render that keeps the container and replaces its children,
which the ancestor observer missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ameerf-wix ameerf-wix closed this Nov 1, 2026
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