Skip to content

Fix nondeterministic dedup caused by stale StyleRuleKey#1236

Open
7rulnik wants to merge 1 commit into
parcel-bundler:masterfrom
7rulnik:fix/style-rule-key-snapshot
Open

Fix nondeterministic dedup caused by stale StyleRuleKey#1236
7rulnik wants to merge 1 commit into
parcel-bundler:masterfrom
7rulnik:fix/style-rule-key-snapshot

Conversation

@7rulnik

@7rulnik 7rulnik commented May 4, 2026

Copy link
Copy Markdown

Fixes #1235.

Heads up: my Rust knowledge is pretty minimal, so I used Claude and Codex to help write the patch. I've reviewed everything, run the existing test suite, and added regression tests, but flagging it so you know.

Approach

Replace HashMap<StyleRuleKey, usize> with HashMap<u64, SmallVec<[usize; 1]>> — buckets of rule indices keyed by rule.hash_key(). Equality is verified against the live rule via is_duplicate on lookup. The merge and cascade-pop sites move/drop the index when a rule's content changes, so entries can't go stale.

This removes the divergent Hash/Eq invariant entirely and avoids the per-rule SelectorList::clone() + PropertyId collection that snapshot-based fix attempts require.

Tests

Five regression tests covering each merge/cascade arm:

  • test_dedup_finds_duplicate_after_merge_collapses_longhands — fails on master.
  • test_dedup_finds_duplicate_after_cascading_merge — fails on master.
  • test_dedup_after_merge_preserves_merged_declarations and test_cascade_pop_clears_orphaned_style_rules_entry — guard a partial fix that snapshots keys but skips map refresh.
  • test_css_module_dedup_skips_cross_file_candidate — covers the CSS-modules cross-file gate against the new bucket-scan lookup.

Performance

Within hyperfine noise on two real-world CSS chunks (7805 and 8717 lines, 500 minify iters × 15 runs each):

Input master this PR Δ
7805 lines 1.346s ± 0.021s 1.368s ± 0.019s +1.6%
8717 lines 1.348s ± 0.012s 1.334s ± 0.015s −1.0%

Deltas land within run-to-run variance and in opposite directions on the two inputs.

`StyleRuleKey` stored a precomputed hash but its `PartialEq` re-read the
live `Vec<CssRule>` by index. After `merge_style_rules` mutated an
indexed rule in place (extending its declarations or selectors), the
stored hash went stale while `Eq` saw the new content, violating the
`Hash`/`Eq` invariant.

The bug surfaces two ways:

  - **Cross-process nondeterminism.** With per-process `ahash` keys, the
    "stale-key lookup hits an unrelated rule" outcome varies between
    processes, so the same input produces different minified output.

  - **Deterministic correctness.** A later rule whose snapshot equals an
    earlier rule's *pre-merge* snapshot can mark the merged rule as
    `Ignored`, silently dropping merged-in declarations. Or a later rule
    whose post-merge content matches a rule's *pre-merge* hash bucket
    can fail to dedup. Example:

        .a { color: red }
        .a { background: blue }    /* merges into rules[0] */
        .b { background: blue }
        .a { color: red }          /* used to wrongly remove rules[0] */

The fix replaces the `HashMap<StyleRuleKey, usize>` (where the key
embedded a precomputed hash beside a live-Vec borrow) with a hash bucket
map: `HashMap<u64, SmallVec<[usize; 1]>>`. The map key is just the
immutable dedup hash; bucket entries carry rule indices, and equality
is verified against the live rule via `is_duplicate` on lookup. This
removes the divergent-Hash/Eq invariant entirely and avoids the per-rule
`SelectorList::clone()` and `PropertyId` collection that an earlier
snapshot-based fix attempt required.

Bookkeeping:

  - On insert: append `idx` to the bucket for `rule.dedup_hash()`.
  - On dedup lookup: scan the bucket, call `is_duplicate` against the
    candidate rule, take the first match.
  - On `merge_style_rules` success: if the rule's hash changed (sel_eq /
    decl_eq selector-extend paths), move `idx` from the old bucket to
    the new. The vendor-prefix-only branch leaves selectors and property
    ids unchanged and short-circuits as a no-op.
  - On `rules.pop()` in the cascade loop: drop the popped rule's bucket
    entry so it can't false-positive against a future rule pushed at
    the same index.

Adds four regression tests:

  - `test_dedup_finds_duplicate_after_merge_collapses_longhands` —
    fails on upstream; missed dedup after sel_eq + shorthand collapse.
  - `test_dedup_finds_duplicate_after_cascading_merge` —
    fails on upstream; missed dedup after cascading sel_eq merges.
  - `test_dedup_after_merge_preserves_merged_declarations` —
    guards a partial fix that snapshots keys but skips map refresh.
  - `test_cascade_pop_clears_orphaned_style_rules_entry` —
    guards the cascade-pop arm of the partial-fix regression.

Performance: within noise of upstream on the captured selene chunks
(7805-line and 8717-line inputs both within ±5% across hyperfine runs).
The randomized per-process `AHasher::default()` keys are no longer
correctness-relevant; they only affect bucket placement, not which
rules are considered duplicates.
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.

Stale StyleRuleKey hash after merge_style_rules causes missed dedup and nondeterministic minified output

1 participant