DEV Community

Ihor Ostin
Ihor Ostin

Posted on AI-assisted

The JavaScript SEO Bug That Wasn't: Before a React Accordion Became an Engineering Ticket

I was ready to turn a clean-looking SEO finding into an engineering ticket.

The first audit pass on a Merge service page found all ten FAQ questions and none of the closed answers. I read that result as a rendering problem: React must be adding each answer to the DOM only after a click.

I had even drafted a possible fix: keep the answers mounted and collapse the closed panels with CSS.

A verification pass then checked the response HTML and browser DOM. It found all ten answers present before anyone opened the accordion, matching the fix I had drafted.

My interpretation had turned invisible text into missing text. I was one step away from asking engineering to fix a bug I had not proved existed.

Disclosure: I work in growth and SEO at Merge, not engineering. I reviewed the audit finding and the technical evidence summarized here; the hands-on checks were run through an AI-assisted research workflow. The article reports observed page behavior, not a verified source-level implementation or an accessibility audit. AI also helped draft and edit this article. I remain responsible for the claims I publish. There are no affiliate links.

I had interpreted visible text as the DOM

A browser exposes several versions of the same page:

  • The HTTP response HTML is what the server returns before browser JavaScript runs.
  • The hydrated DOM is what exists after the application loads and React attaches its behavior.
  • Visible text is what a user can see in the current state, often approximated with innerText.
  • The post-interaction DOM shows whether a click, scroll, or other action creates anything new.

Those versions can disagree. A closed accordion answer may exist in the response and hydrated DOM while innerText omits it. A client-rendered answer may be absent from the response and appear after JavaScript runs. Another component may create the answer node only after a click.

They can look identical on screen, so visible-text extraction cannot diagnose the rendering path on its own.

Google's guidance puts the boundary at user interaction

Google documents a crawl, render, and index process for JavaScript pages. It says Google uses rendered HTML for indexing and runs an evergreen version of Chromium. Google also recommends server-side rendering or pre-rendering because it helps users and crawlers, and because other bots may not execute JavaScript. Content absent from the rendered HTML cannot be indexed.

User interaction is a separate boundary. Google's guidance for incremental loading says its crawlers generally do not click buttons or trigger JavaScript functions that require a user action to update page content.

The useful test follows that boundary: check the response, the hydrated DOM, and any change caused by interaction.

Sources: Google's JavaScript SEO basics and pagination and incremental-loading guidance.

This issue is separate from hidden-text spam. Google defines that abuse by manipulative intent and lists accordions or tabs used for a normal user experience as allowed patterns in its spam policies. Closing a panel with CSS does not by itself violate the policy. For indexing, the intended content still needs to appear in rendered HTML without relying on Google to click the control.

The verification found every answer before a click

The extraction result was accurate. My diagnosis was premature.

innerText is presentation-aware, so it can omit content hidden or collapsed by CSS. textContent reports the text inside an existing node even when the current visual state conceals it.

The verification repeated the test with one unique sentence from each answer. On August 21, 2026, the production check returned HTTP 200 and found all ten exact questions plus one unique phrase from every answer in the server-returned HTML. Every trigger already had an answer panel in the DOM before a click. Each closed panel contained its complete textContent, and the same text remained through a closed, open, and closed cycle.

The FAQ copy changed later, so a fresh Chrome check ran against the production page on August 27. All ten closed answer panels still existed before interaction. They contained between 561 and 711 characters each. The answer followed through the full cycle held exactly 594 characters before opening, while open, and after closing.

Its presentation changed from hidden and zero-height to visible and expanded. The node and its text stayed in place.

That result does not prove Google indexed every answer. It establishes a narrower fact: the tested accordion did not make its content interaction-only during those checks.

I now require three probes before the ticket

Choose a unique phrase that does not also appear in a heading, navigation label, or elsewhere on the page. Confirm that the URL resolves to the expected page with a successful response, then inspect a context window around the phrase.

Probe 1: Check the HTTP response

curl -fsSL https://example.com/page \
  | rg -F -C 3 "a unique sentence from the answer"
Enter fullscreen mode Exit fullscreen mode

A match shows that the final response contains the phrase. Inspect the surrounding markup because a framework may serialize the same text inside a script payload. The useful result is a phrase in document markup, rather than a match that exists only inside application data.

Probe 2: Check the panel before a click

In DevTools, find the trigger through its aria-controls value and inspect the controlled element.

const trigger = [...document.querySelectorAll('button[aria-controls]')]
  .find((button) => button.textContent.includes('Your FAQ question'));

const panel = trigger
  ? document.getElementById(trigger.getAttribute('aria-controls'))
  : null;

console.table({
  expanded: trigger?.getAttribute('aria-expanded'),
  panelExists: Boolean(panel),
  textLength: panel?.textContent.trim().length ?? 0,
  visibleTextLength: panel?.innerText.trim().length ?? 0,
  state: panel?.getAttribute('data-state'),
});
Enter fullscreen mode Exit fullscreen mode

If panelExists is true and textLength is non-zero while visibleTextLength is zero, CSS may be collapsing existing content. That result describes a different situation from a missing node.

Probe 3: Compare the same panel after a click

Record the node and its text, click the trigger, and compare them again.

const before = {
  panel,
  text: panel?.textContent,
};

trigger?.click();

await new Promise(requestAnimationFrame);

const panelAfterClick = trigger
  ? document.getElementById(trigger.getAttribute('aria-controls'))
  : null;

console.table({
  sameNode: before.panel === panelAfterClick,
  sameText: before.text === panelAfterClick?.textContent,
  textLengthAfterClick: panelAfterClick?.textContent.trim().length ?? 0,
});
Enter fullscreen mode Exit fullscreen mode

One animation frame is enough only when the component's state update is synchronous. If a click starts a fetch or delayed transition, wait for the component's settled state or observe the DOM mutation before comparing nodes and text.

A panel that appears only after the click is interaction-dependent. If the node and text remain while state and CSS change, a visible-text audit has measured presentation rather than document availability.

What the page behavior establishes

The production page renders FAQ controls with aria-controls, aria-expanded, data-state, and answer regions before interaction. On the dated checks, each closed answer panel remained in the DOM with its complete textContent; the checked panel changed only its state and computed presentation across a closed, open, and closed cycle.

That establishes observed behavior, not the deployed source-level implementation. I could not verify which repository component, prop, or CSS rule produced it, so I cannot attribute the behavior to forceMount or present it as an SEO decision. This is not an accessibility audit: keyboard operation, focus order, and screen-reader exposure need separate testing.

The three probes lead to four different diagnoses

Server response DOM before click DOM after click Likely interpretation
Phrase present Same text present Same text present Text exists before interaction; a visible-text tool may be omitting it
Phrase absent Text present Same text present Client rendering supplies it; inspect Google's rendered HTML and any other crawler that matters
Phrase absent Node and text absent Text appears Content depends on interaction; do not assume a crawler will trigger it
Phrase present Text disappears Text returns or changes Possible hydration mismatch or client-side regression

The matrix does not predict rankings. It identifies where the text exists and which execution step supplies it.

These checks can catch client-side-fetched primary copy, click-inserted text or links, and server markup removed during hydration. They can also stop a visible-text extractor from creating a false ticket. The same inspection can expose navigation built with a button or click handler instead of an anchor with href, even though the route works for a user.

For a critical interactive component, the release record should keep one expected phrase and link. Check the response HTML, normal document markup rather than only a JSON payload, and the hydrated DOM before interaction. Compare textContent with innerText, open and close the component, and verify that the node and text remain stable. Keyboard focus and screen-reader behavior need their own check, as does the rendered HTML in Google Search Console's URL Inspection tool.

I treat the outcome as technical availability. It is not a promise of indexing, ranking, traffic, conversion, or citation.

This audit came from the FAQ on Merge's front-end development page. That page is the source of the dated checks above; the article makes no claim about the unverified production source implementation.

Reviewing this audit changed how I handle these findings. Before a JavaScript SEO issue becomes an engineering ticket, I require the evidence to name which version of the page failed: the response, the hydrated DOM before interaction, or the DOM after interaction. If the only evidence is missing innerText, the ticket is not ready.

Top comments (0)