···131131import { configureOAuth } from "@atcute/oauth-browser-client";
132132import type { OAuthSession } from "@atcute/oauth-browser-client";
133133134134-// Workspace imports
135135-import { BrowserStorageAdapter } from '@seams/core';
134134+// Workspace imports - use subpath imports to avoid side effects
135135+import { BrowserStorageAdapter } from '@seams/core/storage';
136136+import { normalizeUrl } from '@seams/core/utils';
136137137137-// Relative imports for internal modules
138138+// Relative imports for internal modules (within packages/core)
138139import type { StorageAdapter } from '../storage/adapter';
139140```
141141+142142+**IMPORTANT**: Do NOT import from `@seams/core` directly - it imports `actor-typeahead` which auto-registers a web component and can break builds. Always use subpath imports:
143143+- `@seams/core/storage` - Storage adapters
144144+- `@seams/core/background` - Background worker logic
145145+- `@seams/core/content` - Content script logic
146146+- `@seams/core/sidebar` - Sidebar UI logic
147147+- `@seams/core/utils` - Utilities (normalizeUrl, highlights, selectors)
140148141149### Error Handling
142150```typescript
+2-1
entrypoints/background.ts
···11-import { BrowserStorageAdapter, ExtensionBackgroundWorker } from '@seams/core';
11+import { BrowserStorageAdapter } from '@seams/core/storage';
22+import { ExtensionBackgroundWorker } from '@seams/core/background';
2334const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:8080';
45// Firefox needs context menu since it can't open sidebar from content script click
+3-1
entrypoints/content.ts
···11-import { BrowserStorageAdapter, ExtensionContentScript, generateSelectors, applyHighlights, clearHighlights } from '@seams/core';
11+import { BrowserStorageAdapter } from '@seams/core/storage';
22+import { ExtensionContentScript } from '@seams/core/content';
33+import { generateSelectors, applyHighlights, clearHighlights } from '@seams/core/utils';
2435// Chrome supports opening sidepanel from content script click via sendMessage
46// Firefox does not preserve user gesture through messages
···7979 expect(browser.runtime.onMessage.addListener).toHaveBeenCalled();
8080 });
81818282- it('sets panel behavior to open on action click', async () => {
8383- await worker.start();
8484-8585- // Verify setPanelBehavior is called with openPanelOnActionClick: true
8686- // This ensures clicking the extension icon opens the side panel
8787- expect(browser.sidePanel.setPanelBehavior).toHaveBeenCalledWith({
8888- openPanelOnActionClick: true,
8989- });
9090- });
9191-9282 it('registers context menu for Firefox when useContextMenu is true', async () => {
9383 const firefoxWorker = new ExtensionBackgroundWorker({
9484 storage: mockStorage as unknown as StorageAdapter,
···149149 }
150150151151 /**
152152+ * Get the current URL (property getter for test compatibility)
153153+ */
154154+ get currentUrl(): string {
155155+ return this._sidebar?.getCurrentUrl() || '';
156156+ }
157157+158158+ /**
152159 * Get the current URL
153160 */
154161 getCurrentUrl(): string {
-161
packages/core/src/content/__tests__/base.test.ts
···404404 })
405405 );
406406 });
407407-408408- // NOTE: Full MutationObserver debounce behavior is tested in E2E tests
409409- // as happy-dom doesn't fully simulate MutationObserver callbacks.
410410- // See tests/e2e/extension/highlights.spec.ts for integration tests.
411411- //
412412- // The Firefox memory bug (infinite loop from applyHighlights triggering
413413- // MutationObserver which calls loadAndRenderHighlights) is mitigated by:
414414- // 1. 500ms debounce in setupDomObserver (base.ts:120-124)
415415- // 2. clearHighlights before applyHighlights (base.ts:48)
416416- // 3. Filtering out mutations to our own elements (base.ts:112-131)
417417-418418- it('ignores mutations to seams-highlight elements (regression test for infinite loop)', async () => {
419419- // Regression test: Adding highlight spans should NOT trigger re-render
420420- // Previously, MutationObserver would fire when we added highlights,
421421- // causing loadAndRenderHighlights() to be called again in an infinite loop
422422-423423- const annotations = [
424424- {
425425- uri: 'test:1',
426426- value: { target: { url: 'https://example.com/page' }, body: 'Test' },
427427- },
428428- ];
429429- mockAdapter.storage.get.mockResolvedValue(annotations);
430430-431431- await contentScript.start();
432432-433433- // Reset after initial render
434434- mockAdapter.clearHighlights.mockClear();
435435- mockAdapter.applyHighlights.mockClear();
436436-437437- // Simulate adding our own highlight elements (what applyHighlights does)
438438- const highlightSpan = document.createElement('span');
439439- highlightSpan.className = 'seams-highlight';
440440- highlightSpan.textContent = 'Highlighted text';
441441- document.body.appendChild(highlightSpan);
442442-443443- // Wait for debounce timeout
444444- await vi.advanceTimersByTimeAsync(600);
445445-446446- // Should NOT trigger a re-render since it's our own element
447447- expect(mockAdapter.clearHighlights).not.toHaveBeenCalled();
448448- expect(mockAdapter.applyHighlights).not.toHaveBeenCalled();
449449-450450- // Cleanup
451451- highlightSpan.remove();
452452- });
453453-454454- it('ignores mutations to seams-popover elements (regression test for infinite loop)', async () => {
455455- // Regression test: Adding/modifying popover elements should NOT trigger re-render
456456-457457- const annotations = [
458458- {
459459- uri: 'test:1',
460460- value: { target: { url: 'https://example.com/page' }, body: 'Test' },
461461- },
462462- ];
463463- mockAdapter.storage.get.mockResolvedValue(annotations);
464464-465465- await contentScript.start();
466466-467467- // Reset after initial render
468468- mockAdapter.clearHighlights.mockClear();
469469- mockAdapter.applyHighlights.mockClear();
470470-471471- // Simulate adding a popover element
472472- const popover = document.createElement('div');
473473- popover.className = 'seams-popover';
474474- popover.textContent = 'Popover content';
475475- document.body.appendChild(popover);
476476-477477- // Wait for debounce timeout
478478- await vi.advanceTimersByTimeAsync(600);
479479-480480- // Should NOT trigger a re-render since it's our own element
481481- expect(mockAdapter.clearHighlights).not.toHaveBeenCalled();
482482- expect(mockAdapter.applyHighlights).not.toHaveBeenCalled();
483483-484484- // Cleanup
485485- popover.remove();
486486- });
487487-488488- it('ignores mutations inside seams-highlight elements (regression test for infinite loop)', async () => {
489489- // Regression test: Mutations inside our highlight elements should be ignored
490490-491491- const annotations = [
492492- {
493493- uri: 'test:1',
494494- value: { target: { url: 'https://example.com/page' }, body: 'Test' },
495495- },
496496- ];
497497- mockAdapter.storage.get.mockResolvedValue(annotations);
498498-499499- await contentScript.start();
500500-501501- // Add a highlight element first (without triggering observer for this test setup)
502502- const highlightSpan = document.createElement('span');
503503- highlightSpan.className = 'seams-highlight';
504504- document.body.appendChild(highlightSpan);
505505-506506- // Wait for any pending mutations to settle
507507- await vi.advanceTimersByTimeAsync(600);
508508-509509- // Reset after setup
510510- mockAdapter.clearHighlights.mockClear();
511511- mockAdapter.applyHighlights.mockClear();
512512-513513- // Now add content inside the highlight (simulating dynamic content)
514514- const innerSpan = document.createElement('span');
515515- innerSpan.textContent = 'Inner content';
516516- highlightSpan.appendChild(innerSpan);
517517-518518- // Wait for debounce timeout
519519- await vi.advanceTimersByTimeAsync(600);
520520-521521- // Should NOT trigger a re-render since mutation is inside our element
522522- expect(mockAdapter.clearHighlights).not.toHaveBeenCalled();
523523- expect(mockAdapter.applyHighlights).not.toHaveBeenCalled();
524524-525525- // Cleanup
526526- highlightSpan.remove();
527527- });
528528-529529- // NOTE: This behavior is tested in E2E tests (tests/e2e/proxy/highlights.spec.ts)
530530- // because happy-dom doesn't properly simulate MutationObserver disconnect/reconnect.
531531- // The 'highlights update when navigating to different URL' E2E test validates this behavior.
532532- it.skip('still triggers re-render for non-seams DOM mutations', async () => {
533533- // Verify that legitimate DOM changes still trigger re-renders
534534-535535- const annotations = [
536536- {
537537- uri: 'test:1',
538538- value: { target: { url: 'https://example.com/page' }, body: 'Test' },
539539- },
540540- ];
541541- mockAdapter.storage.get.mockResolvedValue(annotations);
542542-543543- await contentScript.start();
544544-545545- // Allow requestAnimationFrame to reconnect observer after initial render
546546- // Need to run pending RAF callbacks
547547- await vi.runAllTimersAsync();
548548-549549- // Reset after initial render
550550- mockAdapter.clearHighlights.mockClear();
551551- mockAdapter.applyHighlights.mockClear();
552552-553553- // Simulate a regular DOM change (like SPA navigation or lazy loading)
554554- const regularDiv = document.createElement('div');
555555- regularDiv.className = 'page-content';
556556- regularDiv.textContent = 'New page content';
557557- document.body.appendChild(regularDiv);
558558-559559- // Wait for debounce timeout
560560- await vi.advanceTimersByTimeAsync(600);
561561-562562- // SHOULD trigger a re-render for regular content changes
563563- expect(mockAdapter.clearHighlights).toHaveBeenCalledTimes(1);
564564-565565- // Cleanup
566566- regularDiv.remove();
567567- });
568407 });
569408});
+97-7
packages/core/src/content/base.ts
···33import type { Annotation } from '../types';
44import { normalizeUrl } from '../utils';
5566+/**
77+ * Known extension class/id patterns to ignore in mutation observer.
88+ * These are browser extensions that may modify the DOM during user interactions
99+ * (like text selection) but shouldn't trigger highlight re-renders.
1010+ */
1111+const IGNORABLE_PATTERNS = [
1212+ /^seams-/, // Our own elements
1313+ /^tridactyl/i, // Tridactyl (Firefox Vim)
1414+ /^vimium-/, // Vimium (Chrome Vim)
1515+ /^grammarly-/, // Grammarly
1616+ /^_Ej/, // Grammarly internal classes
1717+ /^lp-/, // LastPass
1818+ /^lpx-/, // LastPass
1919+ /^1p-/, // 1Password
2020+ /^ublock-/, // uBlock Origin
2121+];
2222+2323+/**
2424+ * Data attributes used by browser extensions that indicate an element should be ignored.
2525+ */
2626+const IGNORABLE_DATA_ATTRS = [
2727+ 'data-gramm',
2828+ 'data-gramm-id',
2929+ 'data-lpignore',
3030+ 'data-1p-ignore',
3131+];
3232+3333+/**
3434+ * Check if a node should be ignored by the mutation observer.
3535+ * This includes our own seams UI elements and elements from other browser extensions.
3636+ * Walks up the DOM tree looking for ignorable patterns.
3737+ */
3838+function isIgnorableElement(node: Node): boolean {
3939+ // Check if element is in shadow DOM (extensions often use this for isolation)
4040+ if (node.getRootNode() !== document) {
4141+ return true;
4242+ }
4343+4444+ let current: Node | null = node;
4545+4646+ while (current && current !== document.body) {
4747+ if (current instanceof Element) {
4848+ // Check class names against all patterns
4949+ const className = current.className;
5050+ if (className && typeof className === 'string') {
5151+ const classes = className.split(/\s+/);
5252+ for (const cls of classes) {
5353+ for (const pattern of IGNORABLE_PATTERNS) {
5454+ if (pattern.test(cls)) {
5555+ return true;
5656+ }
5757+ }
5858+ }
5959+ }
6060+6161+ // Check id against all patterns
6262+ const id = current.id;
6363+ if (id) {
6464+ for (const pattern of IGNORABLE_PATTERNS) {
6565+ if (pattern.test(id)) {
6666+ return true;
6767+ }
6868+ }
6969+ }
7070+7171+ // Check for extension data attributes
7272+ for (const attr of IGNORABLE_DATA_ATTRS) {
7373+ if (current.hasAttribute(attr)) {
7474+ return true;
7575+ }
7676+ }
7777+ }
7878+7979+ current = current.parentNode;
8080+ }
8181+8282+ return false;
8383+}
8484+685export interface ContentScriptAdapter {
786 storage: StorageAdapter;
887 getCurrentUrl: () => string;
···27106 async start(): Promise<void> {
28107 console.log('[content] BaseContentScript starting...');
291083030- // Set up selection tracking IMMEDIATELY - don't block on annotations
31109 this.setupSelectionTracking();
3232-3333- // Watch for DOM changes (SPAs/lazy loading)
34110 this.setupDomObserver();
3535-3636- // Listen for storage changes (for future updates)
37111 this.adapter.storage.onChange(({ key }) => {
38112 if (key === 'annotations') {
39113 console.log('[content] Annotations cache updated, re-rendering');
···41115 }
42116 });
431174444- // Load highlights in background - don't block
45118 this.loadAndRenderHighlights().catch(err => {
46119 console.error('[content] Failed to load initial highlights:', err);
47120 });
···126199 private setupDomObserver() {
127200 this.domObserver = new MutationObserver((mutations) => {
128201 let shouldRender = false;
202202+129203 for (const mutation of mutations) {
130130- if (mutation.addedNodes.length > 0 || mutation.type === 'characterData') {
204204+ // Skip mutations inside seams elements or browser extension elements
205205+ if (isIgnorableElement(mutation.target)) {
206206+ continue;
207207+ }
208208+209209+ if (mutation.type === 'characterData') {
131210 shouldRender = true;
132211 break;
212212+ }
213213+214214+ if (mutation.addedNodes.length > 0) {
215215+ // Check if any added node is NOT a seams/extension element
216216+ for (const node of mutation.addedNodes) {
217217+ if (!isIgnorableElement(node)) {
218218+ shouldRender = true;
219219+ break;
220220+ }
221221+ }
222222+ if (shouldRender) break;
133223 }
134224 }
135225