Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions extension/src/lib/__tests__/css-hide-stylesheet.property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

// Property tests for injectHideStylesheet. Two lifecycle invariants
// that scenario tests are bad at exhausting (plus one targeted
// orphan-cleanup scenario that the properties can't express cleanly):
//
// - After remove() on every active handle, no <style> with the
// configured id remains anywhere — across arbitrary interleavings
// of inject / removeHandle / pageRemovesStyle / attachShadow.
// - After remove() on every active handle, no open shadow root
// carries an adopted sheet from any of those handles. Inner-helper
// idempotence is covered by shadow-stylesheets.property.test.ts;
// here we assert the outer helper doesn't leak the AdoptedShadowSheet
// reference past its remove() call.

import fc from "fast-check";

import { injectHideStylesheet } from "../css-hide-stylesheet";
import {
__resetShadowRootsForTesting,
getOpenShadowRoots,
installShadowRootHook,
} from "../shadow-roots";

const STYLE_ID = "abs-css-hide-property-test";
const SELECTORS = [".prop-test-target"] as const;

beforeEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
__resetShadowRootsForTesting();
installShadowRootHook();
});

afterEach(() => {
__resetShadowRootsForTesting();
// Clear any stray <style> that escaped a test.
for (const element of document.querySelectorAll(`#${STYLE_ID}`)) {
element.remove();
}
});

// Op alphabet (lifecycle ops only — orphan cleanup is exercised by a
// dedicated scenario test below, because it only fires synchronously
// with .remove() and is awkward to assert as a state-machine invariant):
// { kind: "inject" } — call injectHideStylesheet, retain handle
// { kind: "removeHandle" } — call .remove() on the oldest active handle
// { kind: "pageRemovesStyle" } — simulate page JS removing the <style>
// { kind: "attachShadow" } — open a new shadow root
const opArb = fc.oneof(
fc.record({ kind: fc.constant<"inject">("inject") }),
fc.record({ kind: fc.constant<"removeHandle">("removeHandle") }),
fc.record({ kind: fc.constant<"pageRemovesStyle">("pageRemovesStyle") }),
fc.record({ kind: fc.constant<"attachShadow">("attachShadow") }),
);

const opSequenceArb = fc.array(opArb, { minLength: 1, maxLength: 20 });

function stylesById(): HTMLStyleElement[] {
return [...document.querySelectorAll<HTMLStyleElement>(`#${STYLE_ID}`)];
}

function run(ops: ReadonlyArray<{ kind: string }>): {
activeHandles: Array<{ remove: () => void }>;
} {
document.head.innerHTML = "";
document.body.innerHTML = "";
__resetShadowRootsForTesting();
installShadowRootHook();

const activeHandles: Array<{ remove: () => void }> = [];

for (const op of ops) {
switch (op.kind) {
case "inject": {
const handle = injectHideStylesheet({
elementId: STYLE_ID,
selectors: SELECTORS,
});
activeHandles.push(handle);
break;
}
case "removeHandle": {
const handle = activeHandles.shift();
handle?.remove();
break;
}
case "pageRemovesStyle": {
// Aggressive page-side <head> cleanup that some sites do.
for (const element of stylesById()) {
element.remove();
}
break;
}
case "attachShadow": {
const host = document.createElement("div");
host.attachShadow({ mode: "open" });
document.body.append(host);
break;
}
}
}

return { activeHandles };
}

// Op sequences that include at least one inject — required for the
// "after drain, no <style> remains" property. Without an inject, no
// remove() ever runs, so any planted orphan would (correctly) persist.
const opSequenceWithInjectArb = opSequenceArb.filter((ops) =>
ops.some((op) => op.kind === "inject"),
);

describe("injectHideStylesheet — properties", () => {
it("after every handle is removed, no <style> with the configured id remains", () => {
fc.assert(
fc.property(opSequenceWithInjectArb, (ops) => {
const { activeHandles } = run(ops);

// Drain all live handles. The contract: once every handle has
// been removed, the helper's defensive cleanup must take out any
// orphan <style#STYLE_ID> in the document — whether planted by
// a prior session, by page JS, or by a parallel handle that was
// already remove()d.
while (activeHandles.length > 0) {
activeHandles.shift()?.remove();
}

expect(stylesById()).toHaveLength(0);
}),
);
});

it("remove() cleans up orphan <style> elements planted before inject", () => {
// Scenario: a prior session left an orphan <style#id> in <head>
// (e.g., navigation interrupted teardown, HMR hot-swap). The next
// inject/remove cycle should leave the document clean.
const orphan = document.createElement("style");
orphan.id = STYLE_ID;
orphan.textContent = "/* leftover */";
document.head.append(orphan);

const handle = injectHideStylesheet({
elementId: STYLE_ID,
selectors: SELECTORS,
});
// Both the orphan and the newly-injected style coexist.
expect(stylesById().length).toBeGreaterThanOrEqual(2);

handle.remove();
expect(stylesById()).toHaveLength(0);
});

it("after every handle is removed, no open shadow root carries a sheet from us", () => {
fc.assert(
fc.property(opSequenceArb, (ops) => {
const { activeHandles } = run(ops);

// Snapshot the set of sheets currently adopted across every
// open shadow root *before* draining handles — these are the
// sheets we expect to disappear.
const sheetsBefore = new Set<CSSStyleSheet>();
for (const root of getOpenShadowRoots()) {
for (const sheet of root.adoptedStyleSheets) {
sheetsBefore.add(sheet);
}
}

while (activeHandles.length > 0) {
activeHandles.shift()?.remove();
}

// After teardown, no shadow root should still carry any of the
// pre-teardown sheets. (If a future helper change leaked the
// adoptedSheet reference past remove(), the sheet would
// persist.)
for (const root of getOpenShadowRoots()) {
for (const sheet of root.adoptedStyleSheets) {
expect(sheetsBefore.has(sheet)).toBe(false);
}
}
}),
);
});
});
76 changes: 76 additions & 0 deletions extension/src/lib/css-hide-stylesheet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

// Inject a `display:none !important` stylesheet for a known list of static
// selectors, and adopt the same sheet into every open shadow root. Used by
// hide rules whose matches need no JS-side processing (no placeholder UI,
// no per-element side effects, no candidate filtering) — the CSS engine
// matches and hides for us, even for lazily-injected nodes, with no
// MutationObserver on our side.

import type { AdoptedShadowSheet } from "./shadow-stylesheets";
import { adoptStylesheetIntoShadowRoots } from "./shadow-stylesheets";

export interface HideStylesheet {
// Tear down the document-scope <style> element and stop adopting the
// sheet into shadow roots. Idempotent.
remove: () => void;
}

export interface InjectHideStylesheetOptions {
// DOM id stamped on the injected <style>. Used for re-entry idempotence
// and for orphan cleanup if a previous instance survived without
// teardown (e.g., navigation interrupted before teardown ran).
elementId: string;
selectors: readonly string[];
}

export function injectHideStylesheet(
options: InjectHideStylesheetOptions,
): HideStylesheet {
const { elementId, selectors } = options;
const cssText = selectors
.map((selector) => `${selector}{display:none!important}`)
.join("\n");

let styleElement: HTMLStyleElement | null = null;
let adoptedSheet: AdoptedShadowSheet | null = null;

function inject(): void {
if (styleElement?.isConnected) {
return;
}
const element = document.createElement("style");
element.id = elementId;
element.textContent = cssText;
document.head.append(element);
styleElement = element;
// Document stylesheets do not cross shadow boundaries — anything
// rendered inside a web-component shadow tree would otherwise stay
// visible. The adopted-shadow-sheet path shares one CSSStyleSheet
// across every open shadow root (existing + future).
//
// The `isConnected` guard above allows re-entry when page JS removed
// our <style>; the prior adoptedSheet may still be live, so tear it
// down before re-adopting to avoid leaking the
// subscribeShadowRootAttached listener.
adoptedSheet?.remove();
adoptedSheet = adoptStylesheetIntoShadowRoots(cssText);
}

inject();

return {
remove() {
styleElement?.remove();
styleElement = null;
adoptedSheet?.remove();
adoptedSheet = null;
// Defensive: drop any orphaned <style> from a prior apply whose
// teardown didn't fire (e.g., page navigated mid-teardown).
for (const element of document.querySelectorAll(`#${elementId}`)) {
element.remove();
}
},
};
}
45 changes: 44 additions & 1 deletion extension/src/lib/placeholder-count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
// can render the total as a toolbar badge. Counts both placeholders that ship
// with a reveal control and elements hidden in-place via display:none.
//
// Two count sources:
//
// 1. JS-path hides — elements stamped with HIDDEN_ATTR or wrapped in a
// PLACEHOLDER_CLASS element by selector-hide-rule. Found via one
// querySelectorAll on the union selector below.
// 2. CSS-first hides — rules that hide via an injected stylesheet
// (chat-widget-hide, etc.) call registerCssFirstSelectors with their
// selector union. We QSA each registered union and add the count. No
// HIDDEN_ATTR is stamped on those elements (the stylesheet does the
// hiding), so they wouldn't be picked up by source #1.
//
// We watch document.body with a single MutationObserver and recount on each
// throttled batch. Recounting via querySelectorAll is cheap relative to the
// work the rules themselves do, and avoids us having to instrument every
Expand All @@ -22,8 +33,35 @@ export interface PlaceholderCountMessage {
count: number;
}

const cssFirstUnions = new Set<string>();
let recountTrigger: (() => void) | null = null;

function noop(): void {
// Returned from registerCssFirstSelectors when the caller passed an
// empty union — nothing to unregister.
}

// Register a CSS-first selector union with the counter. Returns an
// unregister fn. Safe to call before startPlaceholderCountReporter runs
// (the union is recorded and picked up by the first count).
export function registerCssFirstSelectors(union: string): () => void {
if (union.length === 0) {
return noop;
}
cssFirstUnions.add(union);
recountTrigger?.();
return () => {
cssFirstUnions.delete(union);
recountTrigger?.();
};
}

function currentCount(): number {
return document.body.querySelectorAll(COUNT_SELECTOR).length;
let count = document.body.querySelectorAll(COUNT_SELECTOR).length;
for (const union of cssFirstUnions) {
count += document.body.querySelectorAll(union).length;
}
return count;
}

function send(count: number): void {
Expand Down Expand Up @@ -55,6 +93,11 @@ export function startPlaceholderCountReporter(): void {
trailing: true,
});

// Allow registerCssFirstSelectors callers to trigger a recount when a
// rule applies/tears down after startup — otherwise we'd wait for the
// next DOM mutation, which never comes on a static page.
recountTrigger = throttled;

// Initial report — fires whether or not any rule has run yet, so the badge
// clears on pages with no matches.
reportIfChanged();
Expand Down
Loading
Loading