diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06c54f0f54e..bae570de8c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## [next]
+- fix(parser): match class/id selectors exactly so `.st12` is not lost when `.st1` also exists [#8838](https://github.com/fabricjs/fabric.js/issues/8838)
- fix(config): resolve device pixel ratio through env [#11040](https://github.com/fabricjs/fabric.js/pull/11040)
- refactor(env): move runtime env setup to packages [#11039](https://github.com/fabricjs/fabric.js/pull/11039)
- chore(): Monorepo follow up steps [#11033](https://github.com/fabricjs/fabric.js/pull/11033)
diff --git a/src/parser/selectorMatches.spec.ts b/src/parser/selectorMatches.spec.ts
new file mode 100644
index 00000000000..ae56e2c77e9
--- /dev/null
+++ b/src/parser/selectorMatches.spec.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { getFabricWindow } from '../env';
+import { selectorMatches } from './selectorMatches';
+
+function getElement(svg: string) {
+ const parser = new (getFabricWindow().DOMParser)();
+ const doc = parser.parseFromString(svg.trim(), 'text/xml');
+ return doc.documentElement.getElementsByTagName('*')[0];
+}
+
+describe('selectorMatches', () => {
+ it('matches a class selector', () => {
+ const element = getElement(
+ '',
+ );
+ expect(selectorMatches(element, '.st1')).toBe(true);
+ });
+
+ it('does not treat a class as a prefix of a longer class ending in a digit', () => {
+ // element only has class `st1`, so it must NOT match the `.st12` selector
+ const element = getElement(
+ '',
+ );
+ expect(selectorMatches(element, '.st12')).toBe(false);
+ });
+
+ it('matches a class whose name is a numeric-suffixed superset of another class', () => {
+ // regression for #8838: `.st12` was lost because `.st1` matched inside it
+ const element = getElement(
+ '',
+ );
+ expect(selectorMatches(element, '.st12')).toBe(true);
+ expect(selectorMatches(element, '.st1')).toBe(true);
+ });
+
+ it('matches an id selector without treating it as a prefix', () => {
+ const element = getElement(
+ '',
+ );
+ expect(selectorMatches(element, '#a1')).toBe(true);
+ expect(selectorMatches(element, '#a12')).toBe(false);
+ });
+});
diff --git a/src/parser/selectorMatches.ts b/src/parser/selectorMatches.ts
index bc87caa60ee..27cc9c7432c 100644
--- a/src/parser/selectorMatches.ts
+++ b/src/parser/selectorMatches.ts
@@ -5,7 +5,12 @@ export function selectorMatches(
const nodeName = element.nodeName;
const classNames = element.getAttribute('class');
const id = element.getAttribute('id');
- const azAz = '(?![a-zA-Z\\-]+)';
+ // negative lookahead ensuring the matched id/class name is not merely a
+ // prefix of a longer name. CSS identifiers can continue with letters,
+ // digits, hyphens or underscores, so all of them must be excluded here
+ // (previously digits and underscores were missing, so `.st1` wrongly
+ // matched inside `.st12`).
+ const azAz = '(?![\\w\\-])';
let matcher;
// i check if a selector matches slicing away part from it.
// if i get empty string i should match