Skip to content

Stop plugin content scripts from crashing the app on remove - #1456

Merged
SawyerHood merged 2 commits into
mainfrom
bb/investigate-plugin-induced-bb-crashes-thr_zsuf27ua57
Aug 13, 2026
Merged

Stop plugin content scripts from crashing the app on remove#1456
SawyerHood merged 2 commits into
mainfrom
bb/investigate-plugin-induced-bb-crashes-thr_zsuf27ua57

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

Why

A user plugin (File Reveal) wraps host links and buttons in a new span. React still treats the old parent as the owner. The next remove or reorder of that same node throws NotFoundError during commit and can blank the window.

PR #1418 already made removeChild and insertBefore non-fatal. That was not enough. The plugin still stole React-owned nodes, leftover wrappers stayed in the tree, and replaceChild could still throw.

What

  • Refuse a content-script move of a React-owned node to a new parent. Mount, dispose, and observers created during mount all run under that rule.
  • The plugin can still insert its own sibling control. The host node stays in React's tree.
  • Extend the foreign DOM guard to replaceChild and swallow NotFoundError on the existing paths.

Test plan

  • pnpm exec turbo run typecheck --filter=@bb/app
  • pnpm exec vitest run --config vitest.config.ts src/lib/foreign-dom-mutation-guard.test.tsx src/lib/plugin-frontend-reload.test.ts (30 passed)

Risks

The expensive fiber check runs only while a plugin content script (or its observer) is moving a node. React commits hit one integer check and then the native method. Same cost class as the #1418 guard.

AGENT GENERATED: by Grok 4.6

A content script that wraps a React-owned button or link moves that node
out of the tree React expects. The next list remove or reorder then
throws NotFoundError during commit.

Keep the existing removeChild/insertBefore guard, extend it to
replaceChild, and refuse those reparents while a plugin script or its
observer runs. The plugin can still insert sibling controls.
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

I am the Slop Cop. I am reviewing this pull request now. I will check security, code quality, performance, architecture, and product behavior.

native.apply(this, kept);
};

Node.prototype.removeChild = guardedRemoveChild;

@SawyerHood SawyerHood Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Common DOM move operations bypass this wrapper list.

replaceChildren, insertAdjacentElement, and Range.insertNode remain native. A plugin can also call host.replaceWith(group) and then append the detached host node.

These paths move a React-owned node while isolation is active. Please cover each production move path with Chromium tests. Consider an architecture that does not depend on a partial method list.

nativeFragmentPrepend,
);

if (NativeMutationObserver !== null) {

@SawyerHood SawyerHood Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The MutationObserver replacement breaks native constructor behavior.

This function always returns a native observer and ignores new.target. Therefore, an observer subclass does not create an instance of that subclass.

The replacement also works without new. Please preserve both native constructor rules and subclass identity. Add a real browser test for both cases.


act(() => root.unmount());
container.remove();
uninstallForeignDomMutationGuardForTest();

@SawyerHood SawyerHood Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A failed assertion can leave global DOM prototypes changed.

This cleanup runs only after all earlier assertions pass. A failure will affect later tests in the same file.

Please move the uninstall call to afterEach or use a finally block.

@@ -506,38 +507,45 @@ async function mountWithTimeout(
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
const mountPromise = Promise.resolve().then(() =>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Supported asynchronous mounts escape the DOM isolation.

The SDK permits mount to return a Promise. This wrapper clears its state before the first awaited continuation runs.

I reproduced this path in Chromium. A mount used await Promise.resolve() and then moved the React-owned Settings link into a new span. The guard logged no refusal.

Please apply the same policy to supported asynchronous work and later callbacks. Add production-path tests for an async mount, an async disposer, and an event callback.

@SawyerHood SawyerHood left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

ELI5: React owns pieces on a board. This patch stops one plugin move, but other supported moves still get through.

I found four reliability defects that should block the merge. I left a comment-only review, as required.

  1. Async plugin work escapes the guard. The SDK permits async mounts and cleanup functions.

    A Chromium test confirmed the gap. The synchronous plugin move failed, and the Settings link stayed with React.

    The same plugin used one await. It then moved the Settings link into its wrapper, and the guard logged no refusal.

  2. Common DOM move APIs bypass the wrapper list. These APIs include replaceChildren, insertAdjacentElement, and Range.insertNode.

    A plugin can also detach a React node first. The guard then permits the later append because the node has no parent.

  3. The MutationObserver replacement breaks native constructor rules. An observer subclass no longer creates an instance of that subclass.

  4. The SDK test host and plugin documentation do not match the new production restriction.

The new integration test also needs reliable cleanup. An early assertion failure currently leaves global DOM prototypes changed.

The narrow replaceChild fallback looks useful. The broader isolation layer needs a clear contract and one shared conformance test set.

I found no separate security defect. Content scripts already run as trusted same-origin code, so this guard is a reliability control.

I found no duplicate DOM guard. The architectural risk comes from a partial global method list and different production and test-host behavior.

The performance worker measured a 14 percent median increase in a raw append-and-remove benchmark. This result does not prove user-visible delay.

Validation passed for the focused 30 tests and the @bb/app Turbo typecheck. All current GitHub checks also pass.

The final GPT-5.6 review gate returned REQUEST CHANGES. I did not use the GitHub request-changes option.

Hold isolation across async mount and dispose, and drop it if the host
aborts a stuck mount. Cover replaceChildren, insertAdjacentElement, and
Range.insertNode. Keep MutationObserver subclass identity. Restore the
guard after every plugin-frontend test.
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Addressed the SlopCop review on this head:

  • Async mount/dispose now stay isolated until the promise settles. A timed-out mount drops the fence so a stuck plugin cannot freeze later React inserts.
  • Covered replaceChildren, insertAdjacentElement, and Range.insertNode. A detached React node can no longer attach to a foreign parent.
  • MutationObserver is a real subclass again, so new and instanceof match native behavior.
  • Plugin-frontend tests now uninstall the guard in afterEach.

I am not copying this fence into @bb/plugin-sdk/testing. That harness is a unit helper. Isolation is host policy in the app, not an SDK contract.

AGENT GENERATED: by Grok 4.6

@SawyerHood
SawyerHood merged commit 311a12f into main Aug 13, 2026
10 checks passed
@SawyerHood
SawyerHood deleted the bb/investigate-plugin-induced-bb-crashes-thr_zsuf27ua57 branch August 13, 2026 02:52
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