Skip to content

Introduce lint rule: no-invalid-bodytext-parent#2997

Open
marcysutton wants to merge 6 commits intomainfrom
WB-2247-bodytext-lint
Open

Introduce lint rule: no-invalid-bodytext-parent#2997
marcysutton wants to merge 6 commits intomainfrom
WB-2247-bodytext-lint

Conversation

@marcysutton
Copy link
Copy Markdown
Member

@marcysutton marcysutton commented Apr 3, 2026

Summary:

Adds new lint rule for the BodyText Typography component to disallow certain parents:

WB Components

  • Choice, Checkbox, Radio — render a <label> internally; <p> can't be inside a <label>
  • Button, ActivityButton — inline/button context; <p> can't be inside <button>
  • Heading, HeadingLarge, HeadingMedium, HeadingSmall, HeadingXSmall — heading elements can't contain block-level children (question: should we include the older components?)

HTML Elements

  • <button> — inline/interactive element
  • <p> — block element, can't contain another block element
  • <label> — inline element
  • <h1><h6> — heading elements

Styled/custom variants

  • StyledButton
  • StyledP
  • StyledLabel

BodyText itself

  • <BodyText> nested inside another <BodyText> — both default to <p>, which would be <p><p> (invalid HTML). Exception: if the outer BodyText has a block-container tag prop (div, section, article, etc.), it's fine.

Questions:

  • Naming. Do we like the lint rule no-invalid-bodytext-parent?
  • The test cases for the lint rule itself. Do we want to change/add/remove anything?

Issue: WB-2247

Test plan:

  1. Ensure tests pass
  2. Run the lint rule in WB and frontend (In progress)

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 3, 2026

🦋 Changeset detected

Latest commit: 2fcab4a

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

This PR includes changesets to release 1 package
Name Type
@khanacademy/eslint-plugin-wonder-blocks Minor

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 3, 2026

Size Change: +1.31 kB (+1.08%)

Total Size: 123 kB

📦 View Changed
Filename Size Change
packages/eslint-plugin-wonder-blocks/dist/es/index.js 1.93 kB +1.31 kB (+210.45%) 🆘
ℹ️ View Unchanged
Filename Size
packages/wonder-blocks-accordion/dist/es/index.js 3 kB
packages/wonder-blocks-announcer/dist/es/index.js 2.43 kB
packages/wonder-blocks-badge/dist/es/index.js 2.02 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.91 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 755 B
packages/wonder-blocks-button/dist/es/index.js 4.28 kB
packages/wonder-blocks-card/dist/es/index.js 1.08 kB
packages/wonder-blocks-cell/dist/es/index.js 2.18 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.6 kB
packages/wonder-blocks-core/dist/es/index.js 2.59 kB
packages/wonder-blocks-data/dist/es/index.js 5.48 kB
packages/wonder-blocks-date-picker/dist/es/index.js 8.06 kB
packages/wonder-blocks-dropdown/dist/es/index.js 19.7 kB
packages/wonder-blocks-form/dist/es/index.js 6.3 kB
packages/wonder-blocks-grid/dist/es/index.js 1.24 kB
packages/wonder-blocks-icon-button/dist/es/index.js 4.01 kB
packages/wonder-blocks-icon/dist/es/index.js 1.91 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.47 kB
packages/wonder-blocks-layout/dist/es/index.js 1.63 kB
packages/wonder-blocks-link/dist/es/index.js 1.53 kB
packages/wonder-blocks-modal/dist/es/index.js 7.36 kB
packages/wonder-blocks-pill/dist/es/index.js 1.31 kB
packages/wonder-blocks-popover/dist/es/index.js 4.36 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.48 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.1 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.55 kB
packages/wonder-blocks-tabs/dist/es/index.js 5.57 kB
packages/wonder-blocks-testing-core/dist/es/index.js 3.25 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-timing/dist/es/index.js 1.37 kB
packages/wonder-blocks-tokens/dist/es/index.js 5.18 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.02 kB
packages/wonder-blocks-typography/dist/es/index.js 1.57 kB

compressed-size-action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 3, 2026

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-dhbduqtijn.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 0
Tests with visual changes 0
Total stories 835
Inherited (not captured) snapshots [TurboSnap] 462
Tests on the build 462

Base automatically changed from feature/eslint-plugin-wb to main April 15, 2026 20:48
@marcysutton marcysutton force-pushed the WB-2247-bodytext-lint branch from 90f9f4b to 4570311 Compare April 16, 2026 17:56
@marcysutton marcysutton marked this pull request as ready for review April 16, 2026 17:56
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@marcysutton marcysutton changed the title [DRAFT] Introduce lint rule: no-invalid-bodytext-parent Introduce lint rule: no-invalid-bodytext-parent Apr 16, 2026
@khan-actions-bot
Copy link
Copy Markdown
Contributor

khan-actions-bot commented Apr 16, 2026

Gerald

Required Reviewers
  • @Khan/wonder-blocks for changes to .eslintrc.js, pnpm-lock.yaml, .changeset/healthy-peas-push.md, packages/eslint-plugin-wonder-blocks/README.md, packages/eslint-plugin-wonder-blocks/package.json, packages/eslint-plugin-wonder-blocks/tsconfig-build.json, __docs__/tools/eslint-plugin-wonder-blocks/no-invalid-bodytext-parent.mdx, packages/eslint-plugin-wonder-blocks/docs/no-invalid-bodytext-parent.md, packages/eslint-plugin-wonder-blocks/src/rules/index.ts, packages/eslint-plugin-wonder-blocks/src/rules/jsx-utils.ts, packages/eslint-plugin-wonder-blocks/src/rules/no-invalid-bodytext-parent.ts, packages/eslint-plugin-wonder-blocks/src/rules/__tests__/jsx-utils.test.ts, packages/eslint-plugin-wonder-blocks/src/rules/__tests__/no-invalid-bodytext-parent.test.ts

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

@khan-actions-bot khan-actions-bot requested a review from a team April 16, 2026 17:56
@marcysutton marcysutton force-pushed the WB-2247-bodytext-lint branch 2 times, most recently from f7321bf to 5b4aed3 Compare April 16, 2026 18:14
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (d1d8e0b) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR2997"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR2997

@marcysutton
Copy link
Copy Markdown
Member Author

@claude review once

Comment thread .eslintrc.js Outdated
Comment on lines +9 to +20
// practices
"plugin:storybook/recommended",
],
plugins: ["import", "jest", "jsdoc", "promise", "monorepo", "react-hooks"],
plugins: [
"import",
"jest",
"jsdoc",
"promise",
"monorepo",
"react-hooks",
"@khanacademy/wonder-blocks",
],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The @khanacademy/wonder-blocks plugin is added to .eslintrc.js's plugins array but no rule from it (e.g., "@khanacademy/wonder-blocks/no-invalid-bodytext-parent": "error") is added to the rules section. As a result, the new lint rule will never fire when running pnpm lint on the monorepo — the primary stated feature of this PR is non-functional.

Extended reasoning...

The bug is straightforward: ESLint plugins do not auto-enable their rules upon registration. Registering a plugin in the plugins array only makes the plugin's rules available — each rule must be independently activated in the rules section with an explicit severity level ("warn", "error", etc.).

In .eslintrc.js, the diff adds "@khanacademy/wonder-blocks" to the plugins array (line 19), but no corresponding rule entry like "@khanacademy/wonder-blocks/no-invalid-bodytext-parent": "error" is added anywhere in the rules block. A search of the entire .eslintrc.js for any wonder-blocks or no-invalid-bodytext-parent entry in the rules section returns nothing.

The plugin source itself does not provide automatic activation: recommended.ts has rules: {} (empty), and strict.ts enables no-custom-tab-role but not no-invalid-bodytext-parent. The @khanacademy/eslint-config base config also does not enable this rule.

The impact is that the entire lint enforcement goal of this PR — preventing BodyText from being nested inside invalid HTML parents — is silently dead on arrival. Developers will not receive lint errors or auto-fix suggestions when they write invalid nesting patterns in the WB codebase. The PR description acknowledges this with "Run the lint rule in WB and frontend (In progress)", confirming the activation step was deferred but not completed.

Step-by-step proof: (1) Plugin is registered: plugins: [..., "@khanacademy/wonder-blocks"] in .eslintrc.js. (2) ESLint resolves the plugin and loads the rule implementations. (3) ESLint checks the rules section for @khanacademy/wonder-blocks/no-invalid-bodytext-parent — finds no entry. (4) Rule severity defaults to "off". (5) Running pnpm lint on any file containing <BodyText> inside a <button> or <p> produces zero lint errors.

The fix is a one-line addition to the rules block: "@khanacademy/wonder-blocks/no-invalid-bodytext-parent": "error". This should be added before merging so the rule is actually enforced in the codebase.

Comment on lines +165 to +185
function buildSpanTagFix(
fixer: TSESLint.RuleFixer,
openingElement: TSESTree.JSXOpeningElement,
): TSESLint.RuleFix {
const existingTagAttr = openingElement.attributes.find(
(a): a is TSESTree.JSXAttribute =>
a.type === "JSXAttribute" &&
a.name.type === "JSXIdentifier" &&
(a.name as TSESTree.JSXIdentifier).name === "tag",
);

if (existingTagAttr?.value) {
return fixer.replaceText(existingTagAttr.value, '"span"');
}

const lastAttr =
openingElement.attributes[openingElement.attributes.length - 1];
return lastAttr
? fixer.insertTextAfter(lastAttr, ' tag="span"')
: fixer.insertTextAfter(openingElement.name, ' tag="span"');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The buildSpanTagFix autofix function has two correctness bugs. First, when BodyText has a boolean tag attribute (<BodyText tag />), the null-value check existingTagAttr?.value fails and the code appends tag="span" after the existing boolean attribute, producing the invalid duplicate-prop JSX <BodyText tag tag="span" />. Second and more critically, when BodyText has a dynamic expression like tag={isLabel ? 'span' : 'code'}, the check succeeds (JSXExpressionContainer is truthy) and fixer.replaceText(existingTagAttr.value, '"span"') silently destroys the developer's conditional logic, replacing it with a static string—running eslint --fix would corrupt code semantics without warning.

Extended reasoning...

Bug 1 — Boolean attribute produces duplicate tag props

In buildSpanTagFix (line 176), the guard is if (existingTagAttr?.value). For a boolean JSX attribute like <BodyText tag />, the AST represents the attribute with value: null. So existingTagAttr is found (non-null), but existingTagAttr.value is null, making the check falsy. The function falls through to the insertTextAfter(lastAttr, ' tag="span"') branch—but at this point lastAttr IS the boolean tag attribute. The fix inserts tag="span" immediately after it, producing <BodyText tag tag="span" />, which is invalid JSX with duplicate props.

Step-by-step proof: Input: <label><BodyText tag>text</BodyText></label>. (1) hasInlineTag returns false because getAttributeStringValue returns null for a boolean attribute. (2) Rule fires, calls buildSpanTagFix. (3) existingTagAttr = the boolean tag JSXAttribute with value === null. (4) existingTagAttr?.valuenull → falsy, branch not taken. (5) lastAttr = that same boolean tag attribute. (6) Result: <BodyText tag tag="span">text</BodyText> — duplicate tag props.

Addressing the refutation: Reviewers correctly note that TypeScript would reject <BodyText tag /> since tag?: string does not accept a boolean. This reduces real-world impact because TypeScript type-checking would catch this pattern first. However, ESLint runs independently of the TypeScript compiler and processes syntactically valid JSX regardless of type correctness. The fixer logic is still incorrect, and if it runs (e.g., in a loosely-typed file, or before TS errors are fixed), it produces broken output. This warrants nit severity at minimum.

Bug 2 — Autofix destructively overwrites dynamic tag expressions

When BodyText has a non-literal tag expression like tag={isLabel ? 'span' : 'code'}, getAttributeStringValue returns null (because the JSXExpressionContainer's expression is a ConditionalExpression, not a Literal), so hasInlineTag returns false and the rule fires. In buildSpanTagFix, existingTagAttr.value is the JSXExpressionContainer object, which is truthy. The branch fixer.replaceText(existingTagAttr.value, '"span"') then replaces the entire {isLabel ? 'span' : 'code'} with the static string "span".

Step-by-step proof: Input: <button><BodyText tag={isLabel ? 'span' : 'code'}>text</BodyText></button>. (1) getAttributeStringValue returns null — isLabel ? 'span' : 'code' is not a Literal. (2) hasInlineTag returns false. (3) Rule fires. (4) buildSpanTagFix finds existingTagAttr.value = JSXExpressionContainer (truthy). (5) fixer.replaceText(existingTagAttr.value, '"span"') executes. (6) Output: <button><BodyText tag="span">text</BodyText></button> — the conditional logic is gone.

Why existing code doesn't prevent it: The if (existingTagAttr?.value) check was intended to distinguish "has a value to replace" from "no value, need to insert," but it does not distinguish between literal values (safe to replace) and dynamic expressions (unsafe to clobber). Any truthy JSX attribute value—whether a string literal, a simple variable reference, or a complex conditional—takes the replacement path.

Fix: For bug 1, check existingTagAttr (not its value) and call fixer.replaceText(existingTagAttr, 'tag="span"') to replace the whole attribute node. For bug 2, before replacing, check whether existingTagAttr.value is a Literal or a JSXExpressionContainer wrapping a Literal; if it's any other expression type, omit the fix callback entirely so eslint --fix leaves the code untouched and only the lint warning is shown.

@marcysutton marcysutton force-pushed the WB-2247-bodytext-lint branch from 5b4aed3 to 906f957 Compare April 16, 2026 19:29
Copy link
Copy Markdown
Member

@beaesguerra beaesguerra left a comment

Choose a reason for hiding this comment

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

Great, thanks for working on our next custom lint rule! I left some questions and notes on how we can document the lint rule 😄 Let me know if you have any questions!

On a side note, it would be neat if we had a tool that could set up the templating for a new lint rule (the files for docs, tests, demo example, etc!). Maybe I'll explore how we could do that next! It would be nice for setting up new packages and components too!

Comment thread package.json Outdated
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "8.17.0",
"@typescript-eslint/parser": "8.17.0",
"@typescript-eslint/rule-tester": "^8.46.3",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is set in the package.json for the eslint-plugin-wonder-blocks package, so I think it isn't needed at the root level!


const rules = {
"no-custom-tab-role": noCustomTabRole,
"no-invalid-bodytext-parent": noInvalidBodyTextParent,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you think this rule should be added to the recommended or strict config?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm leaning toward strict since the rollout in classic apps could be rough. But I could run a test next week to decide?

invalid: [
// --- WB Form: Choice ---
{
code: `<Choice label={<BodyText>Title</BodyText>} />`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we want to handle used with LabeledField props too (label, descripton, errorMessage, contextLabel, readOnlyMessage, additionalHelperMessage)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same with props for RadioGroup and CheckboxGroup (description and errorMessage props)!


// --- HTML heading elements ---
{
code: `<h1><BodyText>Sub text</BodyText></h1>`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we want test cases for StyledH1 elements?

@@ -0,0 +1,343 @@
/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lots of test coverage, nice!!

"Disallow BodyText with a block-level tag (default <p>) inside elements that cannot contain block-level content",
recommended: true,
},
fixable: "code",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool! Our first fixable custom wb lint rule!

* Inline tags that make BodyText safe to nest inside inline/interactive
* contexts (e.g., inside a button, label, or paragraph element).
*/
const INLINE_BODY_TEXT_TAGS = new Set([
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like how you've made it easy to configure different elements/components!

output: `<Choice label={<BodyText tag="span">Title</BodyText>} />`,
},
{
code: `<Choice description={<BodyText>Description</BodyText>} />`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see we don't need to define what props BodyText might be used with (label or description props)! Does that mean it'll check all props for where BodyText without an inline tag shouldn't be used ?


const createRule = ESLintUtils.RuleCreator<WonderBlocksPluginDocs>(
(name) =>
`https://github.com/Khan/wonder-blocks/blob/main/packages/eslint-plugin-wonder-blocks/docs/${name}.md`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you add some docs for the rule in eslint-plugin-wonder-blocks/docs/? Lint errors will include this link with the error message!

Here is an example for a rule doc!

Then we can reference that doc so it shows up in the WB docs. Here is an example of how to do that: https://github.com/Khan/wonder-blocks/blob/main/__docs__/tools/eslint-plugin-wonder-blocks/no-custom-tab-role.mdx

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please also add the rule to the table in the package README!

## Rules
The following shows what rules are enabled in each config:
| Rule | Enabled in `recommended`| Enabled in `strict` |
|------|-------------------------|---------------------|
| [`no-custom-tab-role`](docs/no-custom-tab-role.md)| ||

(the package README is already set up to be used in the WB SB docs 😄)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, good call! I added those!

output: null,
},
],
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you add some of these as examples in the demo project? It will help us verify that the lint rules are being integrated and triggered properly!

@khan-actions-bot khan-actions-bot requested a review from a team April 17, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants