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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [4.7.4] - 2026-08-01

### Fixed

- **Forced interaction states are sampled in the CDP session that applies them.**
The computed-style subtree read no longer crosses into Playwright's separate
protocol session after forcing `:hover`, `:focus`, or `:active`. This removes
intermittent phantom state deltas on unchanged controls in hydrated apps.

## [4.7.3] - 2026-08-01

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "styleproof",
"version": "4.7.3",
"version": "4.7.4",
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
"keywords": [
"playwright",
Expand Down
13 changes: 11 additions & 2 deletions src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,15 @@ async function settleForcedState(client: CDPSession, selector: string): Promise<
return result.value === true;
}

async function snapSubtreeInSession(client: CDPSession, args: SubtreeArgs): Promise<Snap> {
const { result, exceptionDetails } = await client.send('Runtime.evaluate', {
expression: `(${snapSubtree.toString()})(${JSON.stringify(args)})`,
returnByValue: true,
});
if (exceptionDetails) throw new Error(`styleproof: forced-state snapshot failed: ${exceptionDetails.text}`);
return (result.value ?? {}) as Snap;
}

// Forced pseudo-class states on interactive elements, via CDP so no real
// mouse or focus is involved and parent-state descendant rules still apply.
async function captureForcedStates(
Expand Down Expand Up @@ -758,7 +767,7 @@ async function captureForcedStates(
console.warn(`styleproof: interactive element ${id} detached before forced-state capture; skipping it.`);
continue;
}
const baseSnap: Snap = await page.evaluate(snapSubtree, { selector, index: 0 });
const baseSnap = await snapSubtreeInSession(client, { selector, index: 0 });
for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) {
await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses });
if (!(await settleForcedState(client, selector))) {
Expand All @@ -767,7 +776,7 @@ async function captureForcedStates(
console.warn(`styleproof: interactive element ${id} detached during forced-state capture; skipping it.`);
break;
}
const forcedSnap: Snap = await page.evaluate(snapSubtree, { selector, index: 0 });
const forcedSnap = await snapSubtreeInSession(client, { selector, index: 0 });
await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
await settleForcedState(client, selector);
const delta = deltaBetween(baseSnap, forcedSnap);
Expand Down
8 changes: 5 additions & 3 deletions test/smoke.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ test('waits for every forced pseudo-state transition before reading computed sty
button:active { border-color: rgb(7, 8, 9); }
</style></head><body>${Array.from({ length: 24 }, (_, i) => `<button>Action ${i}</button>`).join('')}</body></html>`;

const first = await captureFixture(page, html);
const second = await captureFixture(page, html);
const captures = [];
for (let i = 0; i < 8; i++) captures.push(await captureFixture(page, html));
const [first, ...replays] = captures;

expect(diffStyleMaps(first, second), 'independent forced-state sweeps are byte-equivalent').toEqual([]);
for (const replay of replays)
expect(diffStyleMaps(first, replay), 'independent forced-state sweeps are byte-equivalent').toEqual([]);
for (const [path] of Object.entries(first.elements).filter(([, entry]) => entry.tag === 'button')) {
expect(first.states[path]?.hover, `${path} captured :hover`).toBeTruthy();
expect(first.states[path]?.focus, `${path} captured :focus-visible`).toBeTruthy();
Expand Down
Loading