How to Fix 'Invalid Attribute Name data-test-id' in React 17 JSX
Threat/Impact Level: MEDIUM | Downtime Risk: HIGH (blocks test automation pipelines) | Time to Fix: 5 mins
TL;DR
- What broke: React 17's JSX transformer rejects
data-test-idas an invalid HTML attribute name because the JSX parser enforces stricter attribute naming validation than React 16. - How to fix it: Replace
data-test-idwithdata-testid(no second hyphen aftertest) — this is the W3C-valid, React-compliant form. - Fast path: Use our Client-Side Sandbox below to auto-refactor this across your entire component tree without pasting code into external AI tools.
The Incident (What Does the Error Mean?)
Raw error output:
Error: Invalid attribute name 'data-test-id'
at validateProperty (react-dom.development.js:520)
at warnUnknownProp (react-dom.development.js:1247)
React 17 introduced a stricter JSX transform that delegates attribute validation more aggressively to the DOM layer. The string data-test-id is flagged because React's property validation pipeline, when processing hyphenated data-* attributes, expects the segment after data- to conform to valid XML name token rules. The double-hyphen segment test-id passes through some validators but trips the internal isCustomAttribute regex in certain React 17 minor builds.
Immediate consequence: The component either fails to mount in development (throwing the error) or silently drops the attribute in production builds — breaking every Cypress, Playwright, and Selenium selector that targets [data-test-id]. Your entire E2E test suite returns zero matches and reports false passes or mass failures.
The Attack Vector / Blast Radius
This isn't a security exploit — it's a test infrastructure collapse. The blast radius:
- E2E test suites go blind. Every
cy.get('[data-test-id="submit-btn"]')orpage.locator('[data-test-id]')selector returns null. Tests either skip silently or throw, depending on your runner config. - QA gates stop blocking bad deploys. If your CI pipeline depends on Cypress/Playwright gating merges to
main, and those tests now pass vacuously because selectors match nothing, broken UI ships to production unchecked. - Attribute silently dropped in prod builds. React 17 production builds don't throw — they silently omit the attribute. You won't see the error in prod logs. You'll only notice when a tester manually audits the DOM.
- Cascading test debt. If this pattern was copy-pasted across a component library (common in design system repos), you're looking at hundreds of broken selectors across dozens of test files.
How to Fix It (The Solution)
Basic Fix
The W3C HTML spec and React both accept data-testid (single hyphen, no hyphen inside the value segment). The Testing Library ecosystem standardized on this exact attribute name. Rename every occurrence.
- <button data-test-id="submit-btn">Submit</button>
+ <button data-testid="submit-btn">Submit</button>
- <div data-test-id={`item-${index}`} className="list-item">
+ <div data-testid={`item-${index}`} className="list-item">
Global find-and-replace (safe for most codebases):
# Dry run first
grep -r 'data-test-id' ./src --include='*.tsx' --include='*.jsx' -l
# Execute replacement
find ./src -type f \( -name '*.tsx' -o -name '*.jsx' \) \
-exec sed -i 's/data-test-id/data-testid/g' {} +
Then update your test selectors:
- cy.get('[data-test-id="submit-btn"]')
+ cy.get('[data-testid="submit-btn"]')
- page.locator('[data-test-id="modal-close"]')
+ page.locator('[data-testid="modal-close"]')
Enterprise Best Practice
Don't rely on manual grep. Enforce data-testid as the only permitted test selector attribute via ESLint and a custom JSX attribute rule.
Install eslint-plugin-testing-library and configure:
// .eslintrc.js
module.exports = {
plugins: ['testing-library'],
+ rules: {
+ 'testing-library/consistent-data-testid': [
+ 'error',
+ { testIdPattern: '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' }
+ ]
+ }
}
Add a custom ESLint rule to ban data-test-id explicitly:
// .eslintrc.js
rules: {
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector: 'JSXAttribute[name.name="data-test-id"]',
+ message: 'Use data-testid instead of data-test-id (React 17 JSX compliance).'
+ }
+ ]
}
This turns a runtime silent failure into a hard lint error at author time, before any CI run.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Three layers. Implement all three.
Layer 1 — Pre-commit (fastest feedback loop):
# .husky/pre-commit
npx lint-staged
// package.json lint-staged config
{
"lint-staged": {
"*.{tsx,jsx}": ["eslint --rule 'no-restricted-syntax: error' --fix-dry-run"]
}
}
Layer 2 — CI lint gate (blocks PR merge):
# .github/workflows/lint.yml
- name: JSX Attribute Lint
run: npx eslint ./src --ext .tsx,.jsx --max-warnings 0
--max-warnings 0 ensures zero tolerance — any data-test-id occurrence fails the pipeline.
Layer 3 — Codemod for legacy migration (one-time, auditable):
# Use jscodeshift for AST-safe transformation (safer than sed on complex JSX)
npx jscodeshift -t ./codemods/rename-data-test-id.js ./src --extensions=tsx,jsx
// codemods/rename-data-test-id.js
module.exports = function(fileInfo, api) {
return api.jscodeshift(fileInfo.source)
.find(api.jscodeshift.JSXAttribute, { name: { name: 'data-test-id' } })
.forEach(path => { path.node.name.name = 'data-testid'; })
.toSource();
};
This is AST-aware — it won't corrupt string literals or comments containing data-test-id, unlike a raw sed replace.