Skip to content

New hash algorithm#1920

Open
pancaspe87 wants to merge 7 commits into
masterfrom
feat/new-hash-algorithm
Open

New hash algorithm#1920
pancaspe87 wants to merge 7 commits into
masterfrom
feat/new-hash-algorithm

Conversation

@pancaspe87

@pancaspe87 pancaspe87 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What is this change?

Adds an opt-in collisionResistantHash option to the Compiled babel plugin / transform that generates atomic class names using a base-62 full-width hash, eliminating hash collisions.
Default is false — output is byte-for-byte identical to today. This release is additive and non-breaking. Teams can opt in incrementally; a future major release flips the default and removes the legacy branch.

Why are we making this change?

Current hash doesn't guarantee unique group hash, especially in large repos or libraries with several nested selectors.
When two rules collide on the group hash, ax() deduplicates them as if they were the same property — silently dropping styles.

How are we making this change?

packages/utils/src/hash.ts

  • Adds hashBase62(str, length) — full 32-bit MurmurHash2 → base-62 (0-9a-zA-Z), zero-padded to fixed width
  • Adds ATOMIC_GROUP_HASH_LENGTH = 6 and ATOMIC_VALUE_HASH_LENGTH = 4 constants
  • Keeps existing hash() (base-36) unchanged — still used for keyframe names, CSS variable names, and asset filenames (deliberately, so filenames stay lowercase and avoid case-insensitive-filesystem collisions)

packages/css/src/plugins/atomicify-rules.ts

  • Adds collisionResistantHash?: boolean option (default false)
  • true_<6-char group><4-char value> (11-char class, e.g. _3iDTPbvLZJ)
  • false → existing _<4-char group><4-char value> (9-char class) — unchanged

packages/react/src/runtime/ax.ts
Done in #1924

Instead of slicing a fixed number of chars from the front, we slice everything except the last 4 (the value hash). Because the value hash is fixed-width (4 chars) in both formats, "everything before the value" is always exactly the full group key — regardless of how long the group is.

During migration, a single page can contain both legacy (from not-yet-rebuilt packages / pre-built npm CSS) and new (from rebuilt packages) classes. This is safe because the two formats produce group keys of different lengths:

  • Legacy group key: _ + 4 chars = 5 chars (e.g. _1e0c)
  • New group key: _ + 6 chars = 7 chars (e.g. _3iDTPb)
    A 5-char string and a 7-char string can never be equal, so a legacy class and a new class can never collide on the same map key — even if they represent the same CSS property. They coexist as separate entries; the CSS cascade (source order / specificity) resolves which wins, exactly as it would for any two unrelated atomic classes. There is no false deduplication across formats.

PR checklist

I have...

[x] Updated or added applicable tests
Because the class-name format changes when collisionResistantHash is on, a large number of fixtures needed updating. This was done deliberately so the test suite documents the target (new) output, while a dedicated suite still guards the legacy path.

Additional tests:

  • packages/react/src/runtime/__tests__/ax.test.ts,
  • packages/utils/src/__tests__/hash.test.ts

[x] Updated the documentation in website/
[x] Added a changeset (if making any changes that affect Compiled's behaviour)

Additionally to checklist
[x] apply same changes to Atlaspack SWC plugins -> will do in another PR, will also revisit the PR template so that we ensure things stay on sync

@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 221ea6e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@compiled/babel-plugin Minor
@compiled/css Minor
@compiled/utils Minor
@compiled/parcel-transformer Patch
@compiled/vite-plugin Patch
@compiled/webpack-loader Patch
@compiled/babel-plugin-strip-runtime Minor
@compiled/parcel-optimizer Patch
@compiled/codemods Patch
@compiled/eslint-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify

netlify Bot commented Jul 14, 2026

Copy link
Copy Markdown

Deploy Preview for compiled-css-in-js canceled.

Name Link
🔨 Latest commit 221ea6e
🔍 Latest deploy log https://app.netlify.com/projects/compiled-css-in-js/deploys/6a5dcb401f6d780007213888

Comment thread packages/babel-plugin/src/utils/compress-class-names-for-runtime.ts Outdated

@kylorhall-atlassian kylorhall-atlassian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks good to me from here, but rollout and internal tooling is the hardest part — eg. internal tooling may be bound to 9-digit hashes in places via regex, but that should be an easy search for eslint or otherwise, eg. I personally may have used /_[a-z0-0]{8}/ a few times 🙈 but unclear.

This comment I raised for example https://github.com/atlassian-labs/compiled/pull/1920/changes#r3590535998 will happen both internally and in ecosystem for folks using pass-through styles (where we will have a mismatch of the collisionResistantHash, it's not if, it's when, as we're still going through issues with Emotion vs. Compiled 2 years on)

But it's the right change and it's really just more about comms and process.

Comment on lines +115 to +132
/**
* When `true`, atomic class names are generated with a collision-resistant
* hash: base-62 encoding, zero-padded to a fixed width, producing 11-character
* class names (`_` + 6-char group + 4-char value). When `false` (the default),
* the legacy base-36 hash truncated to 4 characters is used, producing
* 9-character class names — preserving existing output.
*
* This is a migration flag. It can be driven per-product by an environment
* variable or feature gate in the build config. Once every consumer has
* migrated, the default will flip to `true` and the legacy branch removed.
*
* The runtime `ax()` handles both class formats simultaneously (via
* length-based group-key extraction), so mixing old and new classes on a
* page is safe.
*
* @default false
*/
collisionResistantHash?: boolean;

@kylorhall-atlassian kylorhall-atlassian Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Probably could use a website change as well; doesn't have to be a blocker or come in this PR.

This could be where we better document the migration path and possible conflicts with pass-through xcss to link in CHANGELOG / CDAC

Comment on lines +72 to +80
[
'should not cross-dedup legacy 9-char and new 11-char classes for the same property',
// During version-skew, ax() receives old 9-char classes from pre-built packages
// and new 11-char classes from the app's own styles in the same call.
// Different group key lengths mean they never falsely dedup each other.
// CLEANUP: remove this test when collisionResistantHash becomes the default
['_syaz13q2', '_1UtDYzynoA'],
'_syaz13q2 _1UtDYzynoA',
],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We could in theory handle this clash if we stick to base36 for both, but if we can find a way to roll this out, your approach here is best.

There will be conflicts via NPM on how this works though, here's an example:

import { Box } from '@atlaskit/primitives/compiled/box'; // `collisionResistantHash=false` coming from NPM (so you can't re-hash it)
import { cssMap } from '@atlaskit/css'; // `collisionResistantHash=true`

const styles = cssMap({
  border: '1px solid red'
});

export default () => <Box xcss={styles.root} />

This will get, in psuedo-code:

<style>
._abcd1234 { border: none }
._FeDcBa7890 { border: 1px solid red }
</style>
<div class="_abcd1234 _abcdef1234"></div>

Now this works in isolation, but if atomic styles are ordered differently due to arbitrary file imports, things go awry.

The same can be said of the inverse—we'll need to cut a major when we distribute every Platform package with collisionResistantHash=true, but I expect the impact to be VERY MINIMAL as it only affects a minimal set of components…but such a release requires a http://community.developers.atlassian.com/ post and it will be hard to explain it to folks. I think this can be informational only, eg. "As of the next version, if you're using pass-through CSS like <Box xcss> or […], you may see styling conflicts and should opt into collisionResistantHash when bumping. See CHANGELOG for more details" — beyond just informing folks, no further process should be necessary — Tim Keir went through a similar, more complex process w/ TS v4 recently.

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.

Good pints @kylorhall-atlassian

need to cut a major when we distribute every Platform package with collisionResistantHash=true
Yes, that is the plan

Replace the base-36, 4-char-truncated atomic class name hash with a
base-62 encoding of the full 32-bit MurmurHash2 value.

- hashBase62(): new encoder sharing the murmur2 core with hash();
  fixed-width, zero-padded, byte-identical to atlaspack's to_base62
- atomicify-rules: group hash 6 chars, value hash 4 chars (11-char classes)
- ax(): extract group key via className.length - 4 (fast fixed-offset slice);
  old (9-char) and new (11-char) classes are length-disjoint so they never
  falsely deduplicate during migration
- unit tests incl. an atlaspack cross-implementation parity guard

Eliminates the ~93K effective hash space and leading-character bias that
caused class name collisions and silent style drops in large apps.

test: collapse multi-line ax() calls in transform helper

Longer atomic class names (11 chars under the base-62 hash scheme) can push
ax([...]) calls past prettier's default printWidth in the test formatter,
causing them to wrap onto multiple lines. This wrapping is purely a cosmetic
artifact of the test helper — the real emitted code is single-line.

Collapse multi-line ax([...]) calls back to a single line after formatting so
generated-code assertions stay stable regardless of class-name length.

test(keyframes): update fixtures for base-62 class names

test(css-prop): update fixtures for base-62 class names

test(styled): update fixtures for base-62 class names

test(css-map): update fixtures for base-62 class names

test(class-names, xcss-prop): update fixtures for base-62 class names

fix(ac, compress): update group hash length for base-62 6-char groups

ac() and compress-class-names-for-runtime.ts hardcoded the atomic group
hash length as 4 chars (5 with the leading underscore). With the new
base-62 hash, the group is 6 chars (7 with the leading underscore).

- ac.ts: import ATOMIC_GROUP_HASH_LENGTH from @compiled/utils instead of
  hardcoding 5; update isCompressed check from charCodeAt(5) to
  charCodeAt(ATOMIC_GROUP_LENGTH)
- compress-class-names-for-runtime.ts: import ATOMIC_GROUP_HASH_LENGTH
  and use it in slice(1, ATOMIC_GROUP_HASH_LENGTH + 1) instead of
  hardcoded slice(1, 5)
- ac.test.ts: update synthetic test data from 4-char (_aaaa) to 6-char
  (_aaaaaa) group patterns to match new format

test(babel-plugin): update compress and hash-prefix fixtures for base-62

test(babel-plugin): update remaining __tests__ fixtures for base-62 class names

test(strip-runtime, vite-plugin, react, css): update fixtures for base-62 class names

fix(ac): define ATOMIC_GROUP_HASH_LENGTH locally to avoid @compiled/utils runtime dependency
test(react, vite-plugin): update fixtures for base-62 class names
…ism migration flag

- Thread collisionResistantHash through babel-plugin types → transform.ts → atomicify-rules.ts
- atomicClassName branches: false = legacy base-36 9-char (unchanged default), true = base-62 11-char
- Default to collisionResistantHash:true in test helpers (babel-plugin test-utils, strip-runtime
  transform.ts, root babel.config.json, css/vite-plugin test helpers) so fixtures document
  the new output format while shipped default remains legacy
- Add legacy regression suite to atomicify-rules.test.ts proving default=false emits exact
  9-char base-36 names unchanged
- Add collision-resistance test to hash.test.ts proving base-62 eliminates collisions
- Remove atlaspack cross-impl parity assertions (not required for correctness)
- Remove inaccurate '~93K effective hash space' claim (actual bias is more complex)
- Downgrade changeset from major to minor (additive opt-in, no breaking change)
Documents a confirmed collision pair (scrollbar-width vs text-anchor, group _1fjg)
found in large production CSS bundles. Tests assert:
1. Legacy hash DOES produce group collisions on real CSS properties (the confirmed bug)
2. Base-62 6-char hash eliminates those collisions (the fix)
- ax: add legacy/new format dedup parity tests and cross-format no-dedup test
  to document correct behaviour during version-skew window. CLEANUP comments
  mark legacy-specific tests for removal when collisionResistantHash defaults to true.
- atomicify-rules: rename cr -> collisionResistant, css -> cssDeclaration for clarity.
  Remove redundant third collision test case.
…+ new hash backward compat

- ac() now locates the compression separator via indexOf and derives the
  uncompressed group via length - value hash, handling both the legacy 4-char
  group (9-char class) and the collision-resistant 6-char group (11-char class)
- compress-class-names-for-runtime derives group length from the class instead
  of hardcoding it, keeping legacy class-name compression backward compatible
- add legacy-format ac() cases and a dedicated compress helper unit test
- remove migration-artifact comment; tighten vite extraction regex
@pancaspe87
pancaspe87 force-pushed the feat/new-hash-algorithm branch from ae251de to 072aded Compare July 20, 2026 07:09
@pancaspe87 pancaspe87 changed the title [WIP] new hash algorithm New hash algorithm Jul 20, 2026
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.

3 participants