fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers - #2701

Open
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders
Open

fix(ui-modal): avoid redundant Modal.Body re-renders from its observers#2701
balzss wants to merge 1 commit into
masterfrom
INSTUI-5166_modalbody_redundant_rerenders

Conversation

@balzss

Copy link
Copy Markdown
Contributor

Summary

  • Modal.Body's resize/mutation observers called forceUpdate() on every observed change, so any DOM change inside the body re-rendered it even when the derived tabIndex was identical. In jsdom test suites those updates land outside act(), which is what consumers on 11.7.4 are seeing.
  • Derive needsTabIndex into state and compare against the last computed value before calling setState. The comparison has to precede setState — for class components it schedules (and warns) before the updater runs, so bailing out inside the updater is too late.
  • render() no longer reads live DOM geometry; it reads state, and the DOM reads moved into syncTabIndex.
  • Same change in v1 and v2. No prop, theme, or export changes.

Measured on the branch: 20 no-op mutations inside the body went from 20 re-renders to 0 (real Chromium), and 10 mutations from 10 act warnings to 0 (jsdom + RTL, both versions).

Test Plan

  • Keyboard-only: open a modal with a scrollable body and no focusable children, Tab to the body, confirm it takes focus and Up/Down scroll it.
  • With the modal still open, toggle a focusable child into the body — the body should stop being a tab stop and focus should go to the child instead. Toggle it back out and the body should be focusable again.
  • Toggle the body between scrollable and not while open; the tab stop should follow.
  • Worth a screen-reader pass (VO/NVDA/JAWS) on the scrollable-body aria-label, since this touches the same code path as INSTUI-5046.

Fixes INSTUI-5166

🤖 Generated with Claude Code

The resize and mutation observers called forceUpdate() on every observed
change, re-rendering even when the derived tabIndex was identical. Derive
needsTabIndex into state and compare before calling setState, which also
stops the act() warnings consumers see in jsdom test suites.
Applies to v1 and v2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balzssbalzss self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2701/

Built to branch gh-pages at 2026-08-28 09:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

github-actionsBot pushed a commit that referenced this pull request Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression report

Cypress suite: ✅ Passing

Visual diff:No changes.

StatusCount
Unchanged96
Changed0
New0
Removed0

Accessibility (axe): ✅ No violations.

📊 View full report — click a screenshot's ⚠ badge to see each violation boxed on the image, with the offending element named and contrast failures shown as color swatches.

Baselines come from the visual-baselines branch. They refresh on every merge to master. The Cypress suite line covers the a11y and console-error assertions — a ❌ there means the suite found real issues even if the visual diff is clean.

@matyasfmatyasf 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.

Note: Please rephrase Claudespeak, there are some parts that are really hard to understand, e.g.

...so any DOM change inside the body re-rendered it even when the derived tabIndex was identical

what is a "derived tabIndex"??

This way a change might not be noticed in some cases, add this.syncTabIndex() to componentDidUpdate(). A failing case:

a failing test (just use this with less comments :) ):

 it('becomes a tab stop when the content grows without a DOM structure change', async () => {
mockScrollable(false)
const { rerender } = await render(<ModalBody>{'short body'}</ModalBody>)
const body = page.getByText('short body').element()
expect(body).not.toHaveAttribute('tabindex')
// The body is already at its max height, so growing the content changes
// `scrollHeight` but not the body's own box: the ResizeObserver stays
// silent. React updates a lone text child by writing `nodeValue`, which
// is a `characterData` mutation the MutationObserver does not subscribe
// to, so it stays silent too. A late webfont swap or an image finishing
// load looks the same from here.
scrollHeightSpy.mockReturnValue(500)
rerender(<ModalBody>{'a much longer body that now overflows'}</ModalBody>)
// Nothing recomputes `needsTabIndex` any more: `componentDidUpdate` only
// calls `makeStyles`, so the body is scrollable with no focusable child
// and no way for a keyboard user to scroll it.
await vi.waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

and an example to repro:

import React, { useEffect, useState } from 'react'
import { Button, Checkbox, Flex, Modal, Text, View } from '@instructure/ui/latest'
const SHORT_TEXT =
'This body fits inside the fullscreen modal, so it does not scroll and it ' +
'correctly has no tabindex.'
// One long string, so React updates it in place as a single text node.
const LONG_TEXT = Array.from(
{ length: 120 },
(_, i) =>
`Paragraph ${i + 1}. The body now overflows, so a keyboard user needs the ` +
'body itself to be focusable in order to scroll it.'
).join(' ')
const OVERLAY_SCROLLBAR_CSS = `
[data-cid="ModalBody"] {
scrollbar-width: none;
}
[data-cid="ModalBody"]::-webkit-scrollbar {
width: 0;
height: 0;
}
`
type BodyInfo = {
scrollHeight: number
clientHeight: number
clientWidth: number
tabIndex: string | null
focusableChildren: number
}
export function ModalBodyTabIndexPage() {
const [open, setOpen] = useState(true)
const [grown, setGrown] = useState(false)
const [overlayScrollbars, setOverlayScrollbars] = useState(true)
const [info, setInfo] = useState<BodyInfo | null>(null)
// Poll the live DOM instead of the component's state, so the readout shows
// exactly what a Tab press or a screen reader would see. The poll re-renders
// this page (and with it Modal.Body) five times a second, which is the point:
// even that does not bring the tabindex back, because `componentDidUpdate`
// no longer recomputes it.
useEffect(() => {
const read = () => {
const body = document.querySelector<HTMLElement>('[data-cid="ModalBody"]')
if (!body) {
setInfo(null)
return
}
setInfo({
scrollHeight: body.scrollHeight,
clientHeight: body.clientHeight,
clientWidth: body.clientWidth,
tabIndex: body.getAttribute('tabindex'),
focusableChildren: body.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]'
).length
})
}
read()
const id = window.setInterval(read, 200)
return () => window.clearInterval(id)
}, [])
const scrollable = !!info && info.scrollHeight - info.clientHeight > 1
const isBug = scrollable && info?.focusableChildren === 0 && !info?.tabIndex
return (
<View as="div" padding="medium">
{overlayScrollbars ? <style>{OVERLAY_SCROLLBAR_CSS}</style> : null}
<Button onClick={() => setOpen(true)}>Open the modal</Button>
<Modal
open={open}
onDismiss={() => setOpen(false)}
label="Modal.Body tab stop repro"
size="fullscreen"
shouldReturnFocus={false}
>
<Modal.Header>Modal.Body tab stop repro</Modal.Header>
{/* Text only: no focusable children, and a lone text node that React
updates through `nodeValue`. */}
<Modal.Body>{grown ? LONG_TEXT : SHORT_TEXT}</Modal.Body>
<Modal.Footer>
<Flex direction="column" gap="small" alignItems="start">
<Flex gap="small">
<Button color="primary" onClick={() => setGrown(!grown)}>
{grown ? 'Shrink the body text' : 'Grow the body text'}
</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
</Flex>
{/* Shrink the text back before flipping this: turning the gutter on
or off while the body already scrolls changes its width, which
fires the ResizeObserver and recomputes the tab stop. */}
<Checkbox
label="Overlay scrollbars (macOS default) — uncheck for classic scrollbars"
variant="toggle"
size="small"
checked={overlayScrollbars}
onChange={() => setOverlayScrollbars(!overlayScrollbars)}
/>
<Text size="small">
{info
? `scrollHeight ${info.scrollHeight} · clientHeight ${info.clientHeight} · ` +
`clientWidth ${info.clientWidth} · scrollable: ${scrollable} · ` +
`focusable children: ${info.focusableChildren} · ` +
`tabindex: ${info.tabIndex ?? 'absent'}`
: 'body not mounted'}
</Text>
<Text
size="small"
color={isBug ? 'danger' : 'success'}
weight="bold"
>
{isBug
? 'BUG: the body scrolls, holds nothing focusable, and has no tabindex. ' +
'Press Tab — focus skips the body, so its content cannot be reached by keyboard.'
: scrollable
? 'The scrollable body is a tab stop. With classic scrollbars this is ' +
'luck: the scrollbar shrank clientWidth, which woke the ResizeObserver.'
: 'Body is not scrollable yet — press "Grow the body text".'}
</Text>
</Flex>
</Modal.Footer>
</Modal>
</View>
)
}

Comment on lines +131 to +138
// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.

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.

please simplify to

 // The body is a tab stop only while it can be scrolled but holds nothing
// focusable.

Sign up for freeto 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

@balzss@matyasf@git-nandor