Repository: vanilla-extract-css/vanilla-extract
Pull request #1160 (2023-08-10 05:06:17)

Allow custom identifier hashing
Hello, I am submitting a request to reopen Pull Request #657. This proposed enhancement aims to unlock my team, which is currently facing a classname clash issue, we are using multiple micro-frontends within a host application. Unfortunately, some of the generated hash values from these micro-frontends coincide with hash values from the host application. We are really hoping for this feature to be integrated, like that it will allows us to customize the generated classnames by applying prefixes specific to each micro-frontend and will guarantee the uniqueness of every hash.

Moreover, the implementation of this feature could help multiple persons with similar scenarios and other challenges, we can found in the following discussion:

- https://github.com/vanilla-extract-css/vanilla-extract/discussions/313
- https://github.com/vanilla-extract-css/vanilla-extract/discussions/273
- https://github.com/vanilla-extract-css/vanilla-extract/discussions/234

Also, I'd like to highlight a similar feature available in [Material UI](https://mui.com/system/styles/api/#creategenerateclassname-options-class-name-generator) allowing to customize the generated classnames.

Thank you for your consideration.

Best regards,

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: ffd783bc26b1a54053ae97c653269e1ccf699a1a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/SombreroElGringo/vanilla-extract/new/master?filename=.changeset/silent-plums-cheat.md&value=---%0A%22%40vanilla-extract%2Fcss%22%3A%20patch%0A%22site%22%3A%20patch%0A---%0A%0AAllow%20custom%20identifier%20hashing%0A)



Patch:
diff --git a/.changeset/curly-lies-flash.md b/.changeset/curly-lies-flash.md
new file mode 100644
index 0000000..952019a
--- /dev/null
+++ b/.changeset/curly-lies-flash.md
@@ -0,0 +1,10 @@
+---
+'@vanilla-extract/css': minor
+'@vanilla-extract/webpack-plugin': minor
+'@vanilla-extract/esbuild-plugin': minor
+'@vanilla-extract/rollup-plugin': minor
+'@vanilla-extract/vite-plugin': minor
+'@vanilla-extract/next-plugin': minor
+---
+
+Users can now provide a custom identifier hashing function
diff --git a/packages/css/src/identifier.ts b/packages/css/src/identifier.ts
index 5dfe5b1..175f6a0 100644
--- a/packages/css/src/identifier.ts
+++ b/packages/css/src/identifier.ts
@@ -28,6 +28,10 @@ function getDevPrefix({
   return parts.join('_');
 }
 
+function normalizeIdentifier(identifier: string) {
+  return identifier.match(/^[0-9]/) ? `_${identifier}` : identifier;
+}
+
 interface GenerateIdentifierOptions {
   debugId?: string;
   debugFileName?: boolean;
@@ -38,6 +42,7 @@ export function generateIdentifier(options?: GenerateIdentifierOptions): string;
 export function generateIdentifier(
   arg?: string | GenerateIdentifierOptions,
 ): string {
+  const identOption = getIdentOption();
   const { debugId, debugFileName = true } = {
     ...(typeof arg === 'string' ? { debugId: arg } : null),
     ...(typeof arg === 'object' ? arg : null),
@@ -53,13 +58,31 @@ export function generateIdentifier(
 
   let identifier = `${fileScopeHash}${refCount}`;
 
-  if (getIdentOption() === 'debug') {
+  if (identOption === 'debug') {
     const devPrefix = getDevPrefix({ debugId, debugFileName });
 
     if (devPrefix) {
       identifier = `${devPrefix}__${identifier}`;
     }
+
+    return normalizeIdentifier(identifier);
   }
+  if (typeof identOption === 'function') {
+    identifier = identOption({
+      hash: identifier,
+      debugId,
+      filePath,
+      packageName,
+    });
 
-  return identifier.match(/^[0-9]/) ? `_${identifier}` : identifier;
+    if (!identifier.match(/^[A-Z_][0-9A-Z_]+$/i)) {
+      throw new Error(
+        `Identifier function returned invalid indentifier: "${identifier}"`,
+      );
+    }
+
+    return identifier;
+  }
+
+  return normalizeIdentifier(identifier);
 }
diff --git a/packages/css/src/types.ts b/packages/css/src/types.ts
index 7d89d3c..078f0b9 100644
--- a/packages/css/src/types.ts
+++ b/packages/css/src/types.ts
@@ -1,5 +1,5 @@
-import type { MapLeafNodes, CSSVarFunction } from '@vanilla-extract/private';
-import type { Properties, AtRule } from 'csstype';
+import type { CSSVarFunction, MapLeafNodes } from '@vanilla-extract/private';
+import type { AtRule, Properties } from 'csstype';
 
 import type { SimplePseudos } from './simplePseudos';
 
@@ -120,7 +120,15 @@ export interface Composition {
   classList: string;
 }
 
-type IdentOption = 'short' | 'debug';
+type CustomIdentFunction = (params: {
+  hash: string;
+  filePath: string;
+  debugId?: string;
+  packageName?: string;
+}) => string;
+
+type IdentOption = 'short' | 'debug' | CustomIdentFunction;
+
 export interface Adapter {
   appendCss: (css: CSS, fileScope: FileScope) => void;
   registerClassName: (className: string, fileScope: FileScope) => void;
diff --git a/site/docs/integrations/esbuild.md b/site/docs/integrations/esbuild.md
index af1d936..cc1fe83 100644
--- a/site/docs/integrations/esbuild.md
+++ b/site/docs/integrations/esbuild.md
@@ -100,6 +100,13 @@ Different formatting of identifiers (e.g. class names, keyframes, CSS Vars, etc)
 
 - `short` identifiers are a 7+ character hash. e.g. `hnw5tz3`
 - `debug` identifiers contain human readable prefixes representing the owning filename and a potential rule level debug name. e.g. `myfile_mystyle_hnw5tz3`
+- A custom identifier function takes an object parameter with properties `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier. e.g.
+
+```ts
+vanillaExtractPlugin({
+  identifiers: ({ hash }) => `prefix_${hash}`
+});
+```
 
 Each integration will set a default value based on the configuration options passed to the bundler.
 
diff --git a/site/docs/integrations/next.md b/site/docs/integrations/next.md
index 9b1c08b..ebb1caa 100644
--- a/site/docs/integrations/next.md
+++ b/site/docs/integrations/next.md
@@ -57,5 +57,12 @@ Different formatting of identifiers (e.g. class names, keyframes, CSS Vars, etc)
 
 - `short` identifiers are a 7+ character hash. e.g. `hnw5tz3`
 - `debug` identifiers contain human readable prefixes representing the owning filename and a potential rule level debug name. e.g. `myfile_mystyle_hnw5tz3`
+- A custom identifier function takes an object parameter with properties `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier. e.g.
+
+```ts
+const withVanillaExtract = createVanillaExtractPlugin({
+  identifiers: ({ hash }) => `prefix_${hash}`
+});
+```
 
 Each integration will set a default value based on the configuration options passed to the bundler.
diff --git a/site/docs/integrations/rollup.md b/site/docs/integrations/rollup.md
index 2c1e0b8..00bc292 100644
--- a/site/docs/integrations/rollup.md
+++ b/site/docs/integrations/rollup.md
@@ -76,6 +76,13 @@ Different formatting of identifiers (e.g. class names, keyframes, CSS Vars, etc)
 
 - `short` identifiers are a 7+ character hash. e.g. `hnw5tz3`
 - `debug` identifiers contain human readable prefixes representing the owning filename and a potential rule level debug name. e.g. `myfile_mystyle_hnw5tz3`
+- A custom identifier function takes an object parameter with properties `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier. e.g.
+
+```ts
+vanillaExtractPlugin({
+  identifiers: ({ hash }) => `prefix_${hash}`
+});
+```
 
 Each integration will set a default value based on the configuration options passed to the bundler.
 
diff --git a/site/docs/integrations/vite.md b/site/docs/integrations/vite.md
index d600fdc..4564323 100644
--- a/site/docs/integrations/vite.md
+++ b/site/docs/integrations/vite.md
@@ -51,6 +51,13 @@ Different formatting of identifiers (e.g. class names, keyframes, CSS Vars, etc)
 
 - `short` identifiers are a 7+ character hash. e.g. `hnw5tz3`
 - `debug` identifiers contain human readable prefixes representing the owning filename and a potential rule level debug name. e.g. `myfile_mystyle_hnw5tz3`
+- A custom identifier function takes an object parameter with properties `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier. e.g.
+
+```ts
+vanillaExtractPlugin({
+  identifiers: ({ hash }) => `prefix_${hash}`
+});
+```
 
 Each integration will set a default value based on the configuration options passed to the bundler.
 
diff --git a/site/docs/integrations/webpack.md b/site/docs/integrations/webpack.md
index a204db2..6665aa1 100644
--- a/site/docs/integrations/webpack.md
+++ b/site/docs/integrations/webpack.md
@@ -91,5 +91,12 @@ Different formatting of identifiers (e.g. class names, keyframes, CSS Vars, etc)
 
 - `short` identifiers are a 7+ character hash. e.g. `hnw5tz3`
 - `debug` identifiers contain human readable prefixes representing the owning filename and a potential rule level debug name. e.g. `myfile_mystyle_hnw5tz3`
+- A custom identifier function takes an object parameter with properties `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier. e.g.
+
+```ts
+VanillaExtractPlugin({
+  identifiers: ({ hash }) => `prefix_${hash}`
+});
+```
 
 Each integration will set a default value based on the configuration options passed to the bundler.


Test patch:
diff --git a/packages/css/src/identifier.test.ts b/packages/css/src/identifier.test.ts
index 3ef01d6..88a6e88 100644
--- a/packages/css/src/identifier.test.ts
+++ b/packages/css/src/identifier.test.ts
@@ -1,4 +1,5 @@
-import { setFileScope, endFileScope } from './fileScope';
+import { removeAdapter, setAdapter } from './adapter';
+import { endFileScope, setFileScope } from './fileScope';
 import { generateIdentifier } from './identifier';
 
 describe('identifier', () => {
@@ -57,4 +58,43 @@ describe('identifier', () => {
       ).toMatchInlineSnapshot(`"_18bazsm6"`);
     });
   });
+
+  describe('with custom callback', () => {
+    beforeAll(() => {
+      setFileScope('path/to/file.css.ts', 'packagetest');
+      setAdapter({
+        appendCss: () => {},
+        registerClassName: () => {},
+        onEndFileScope: () => {},
+        registerComposition: () => {},
+        markCompositionUsed: () => {},
+        getIdentOption:
+          () =>
+          ({ hash, debugId, filePath, packageName }) => {
+            const filenameWithExtension = filePath?.split('/').pop();
+            const filenameWithoutExtension =
+              filenameWithExtension?.split('.')?.[0];
+
+            return `abc_${debugId}_${hash}_${packageName}_${filenameWithoutExtension}`;
+          },
+      });
+    });
+
+    afterAll(() => {
+      removeAdapter();
+      endFileScope();
+    });
+
+    it('defers to a custom callback', () => {
+      expect(generateIdentifier(`a`)).toMatchInlineSnapshot(
+        `"abc_a_s0xkdr0_packagetest_file"`,
+      );
+    });
+
+    it('rejects invalid identifiers', () => {
+      // getIdentOption() does not remove spaces from the debug info so the
+      // resulting identifier should be invalid here.
+      expect(() => generateIdentifier(`a b`)).toThrow();
+    });
+  });
 });