From 3caf5d99d0b7b795c60028fdf4892f9440712af2 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Wed, 23 Sep 2026 12:06:12 +0200 Subject: [PATCH 1/4] fix(eclipse): keep accordion content in the server-rendered HTML Radix unmounts closed accordion content, so an FAQ built with ships its questions as markup and its answers only as escaped RSC flight data inside a script tag. Verified against the live blog: on /blog/agents-md-for-databases every question appears in the DOM and not one answer body does. That hides the most quotable part of a post from crawlers that do not run JavaScript, which is most AI crawlers. 57 blog posts and the docs use this component. forceMount keeps the panel in the DOM; a closed panel collapses to height 0 and stays clipped by overflow-hidden, so the rendered result is unchanged. Co-Authored-By: Claude Opus 5 --- packages/eclipse/src/components/ui/accordion.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/eclipse/src/components/ui/accordion.tsx b/packages/eclipse/src/components/ui/accordion.tsx index d95eb9d2ae8..fd00f7dbcf4 100644 --- a/packages/eclipse/src/components/ui/accordion.tsx +++ b/packages/eclipse/src/components/ui/accordion.tsx @@ -73,8 +73,15 @@ export function AccordionContent({ }: ComponentProps) { return ( Date: Wed, 23 Sep 2026 13:59:58 +0200 Subject: [PATCH 2/4] fix(eclipse): keep closed accordion panels out of the tab order Follow-up to the forceMount change. Because forceMount suppresses Radix's normal closed `hidden` state, h-0 and overflow-hidden only clip the panel visually: links and buttons inside a closed answer stayed focusable and in the accessibility tree. Accordions now publishes its open values on a context, and Accordion marks its content inert while collapsed. inert removes the subtree from the tab order and the accessibility tree without removing it from the DOM, so the answer text is still in the server-rendered HTML, which was the point of the original change. Co-Authored-By: Claude Opus 5 --- packages/eclipse/src/components/accordion.tsx | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/eclipse/src/components/accordion.tsx b/packages/eclipse/src/components/accordion.tsx index 71408d4911e..4f8b684372b 100644 --- a/packages/eclipse/src/components/accordion.tsx +++ b/packages/eclipse/src/components/accordion.tsx @@ -1,7 +1,17 @@ "use client"; import { Check, Link as LinkIcon } from "lucide-react"; -import { ComponentProps, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + ComponentProps, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { cn } from "../lib/cn"; import { buttonVariants } from "./ui/button"; import { mergeRefs } from "../lib/merge-refs"; @@ -13,6 +23,12 @@ import { AccordionTrigger, } from "./ui/accordion"; +// Which items are open, so a closed panel can be marked inert. Its content stays +// mounted for crawlers (see AccordionContent), and inert keeps any links or +// buttons inside it out of the tab order and the accessibility tree while the +// panel is collapsed. +const OpenValuesContext = createContext([]); + function useCopyButton(copy: () => void | Promise, timeout = 2000) { const [checked, setChecked] = useState(false); @@ -52,7 +68,12 @@ export function Accordions({ if (value) setValue((prev) => (typeof prev === "string" ? value : [value, ...prev])); }, []); - return ( + const openValues = useMemo( + () => (typeof value === "string" ? (value ? [value] : []) : value), + [value], + ); + + const root = ( // @ts-expect-error -- Multiple types ); + + return {root}; } export function Accordion({ @@ -79,13 +102,15 @@ export function Accordion({ title: string | ReactNode; value?: string; }) { + const isOpen = useContext(OpenValuesContext).includes(value); + return ( {title} {id ? : null} - +
{children}
From 44ffe833cb017197c1bc52a0519c6b02e9b60cf8 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Wed, 23 Sep 2026 14:12:50 +0200 Subject: [PATCH 3/4] fix(eclipse): honour a controlled value when marking panels inert Accordions accepts Radix's controlled value and onValueChange, and the {...props} spread put a caller's value onto Root while the inert context still read local state. A controlled root could therefore render a panel open with its content inert, or leave a closed panel interactive. value and onValueChange are now destructured out of props, so the spread cannot reach past them. The root and the context both read the effective value, and onValueChange is composed so local state and the caller's handler both see the change. Co-Authored-By: Claude Opus 5 --- packages/eclipse/src/components/accordion.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/eclipse/src/components/accordion.tsx b/packages/eclipse/src/components/accordion.tsx index 4f8b684372b..fe7ced54781 100644 --- a/packages/eclipse/src/components/accordion.tsx +++ b/packages/eclipse/src/components/accordion.tsx @@ -48,6 +48,8 @@ export function Accordions({ ref, className, defaultValue, + value: controlledValue, + onValueChange, ...props }: ComponentProps) { const rootRef = useRef(null); @@ -68,9 +70,27 @@ export function Accordions({ if (value) setValue((prev) => (typeof prev === "string" ? value : [value, ...prev])); }, []); + // A caller may drive this controlled. Its value has to win for both the root + // and the inert context, or a panel can render open while its content is + // still inert. + const effectiveValue = controlledValue ?? value; + + const handleValueChange = useCallback( + (next: string | string[]) => { + setValue(next); + (onValueChange as ((next: string | string[]) => void) | undefined)?.(next); + }, + [onValueChange], + ); + const openValues = useMemo( - () => (typeof value === "string" ? (value ? [value] : []) : value), - [value], + () => + typeof effectiveValue === "string" + ? effectiveValue + ? [effectiveValue] + : [] + : effectiveValue, + [effectiveValue], ); const root = ( @@ -78,8 +98,8 @@ export function Accordions({ Date: Wed, 23 Sep 2026 12:13:54 +0000 Subject: [PATCH 4/4] fix(eclipse): size accordion animations from the mounted content With forceMount, Radix measures the content node in a layout effect while the closed panel is collapsed to h-0, so its reading is 0 and no re-render follows: the open animation played from 0 to auto, which snaps at the halfway point instead of sliding. On page load the closed panel also animated from its full height to 0 before hydration, flashing every answer open for a frame or two, because the height variable was unset and the accordion-up keyframe falls back to auto. Measure an inner wrapper with a ResizeObserver instead and feed the result to --radix-accordion-content-height, the variable the keyframes read. It is 0px in the server render so a closed panel starts collapsed. Add a server-render test for the behaviour the PR introduces: answers in the markup of a closed panel, inert on closed and not on open, and the variable present so nothing animates on load. --- .../eclipse/src/components/ui/accordion.tsx | 24 ++++++- packages/eclipse/test/accordion.test.tsx | 62 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 packages/eclipse/test/accordion.test.tsx diff --git a/packages/eclipse/src/components/ui/accordion.tsx b/packages/eclipse/src/components/ui/accordion.tsx index fd00f7dbcf4..7ddaf5cbd45 100644 --- a/packages/eclipse/src/components/ui/accordion.tsx +++ b/packages/eclipse/src/components/ui/accordion.tsx @@ -2,7 +2,7 @@ import * as Primitive from "@radix-ui/react-accordion"; import { ChevronRight } from "lucide-react"; -import { type ComponentProps } from "react"; +import { type ComponentProps, type CSSProperties, useLayoutEffect, useRef, useState } from "react"; import { cn } from "../../lib/cn"; export function Accordion({ className, ...props }: ComponentProps) { @@ -69,8 +69,27 @@ export function AccordionTrigger({ export function AccordionContent({ className, children, + style, ...props }: ComponentProps) { + // Radix sizes the open and close animations from a measurement of this node + // taken in a layout effect. With forceMount that measurement happens while + // the panel is closed and collapsed to h-0, so it reads 0 and the open + // animation snaps instead of sliding. Measure the inner wrapper instead: it + // keeps its natural height however the panel is clipped. Before hydration the + // variable is 0px, so a closed panel starts collapsed rather than animating + // from its full height on load. + const inner = useRef(null); + const [height, setHeight] = useState(0); + + useLayoutEffect(() => { + const node = inner.current; + if (!node) return; + const observer = new ResizeObserver(() => setHeight(node.getBoundingClientRect().height)); + observer.observe(node); + return () => observer.disconnect(); + }, []); + return ( - {children} +
{children}
); } diff --git a/packages/eclipse/test/accordion.test.tsx b/packages/eclipse/test/accordion.test.tsx new file mode 100644 index 00000000000..2b7696fc94e --- /dev/null +++ b/packages/eclipse/test/accordion.test.tsx @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Accordion, Accordions } from "../src/components/accordion"; +import { textContent } from "./html-text"; + +// FAQ blocks are the most quotable part of a post, and most AI crawlers do not +// run JavaScript. The answers have to be in the server-rendered HTML, not only +// in the RSC payload, which means a closed panel stays mounted. +const QA: Array<[question: string, answer: string]> = [ + ["What is Prisma?", "Prisma is an ORM for TypeScript."], + ["Is it type safe?", "Yes, end to end."], +]; + +function render(props: { defaultValue?: string } = {}) { + return renderToStaticMarkup( + + {QA.map(([question, answer]) => ( + + {answer} + + ))} + , + ); +} + +function panelTags(html: string): string[] { + return [...html.matchAll(/]*role="region"[^>]*>/g)].map((m) => m[0]); +} + +test("closed panels ship their answers in the server-rendered markup", () => { + const html = render(); + const text = textContent(html); + for (const [question, answer] of QA) { + assert.ok(text.includes(question), `question missing from markup: ${question}`); + assert.ok(text.includes(answer), `answer missing from markup: ${answer}`); + } + + const panels = panelTags(html); + assert.equal(panels.length, QA.length); + for (const panel of panels) { + assert.match(panel, /data-state="closed"/); + assert.doesNotMatch(panel, /\shidden=""/, "a closed panel must not be display:none"); + } +}); + +test("a closed panel is inert and an open one is not", () => { + const [open, closed] = panelTags(render({ defaultValue: QA[0][0] })); + assert.match(open, /data-state="open"/); + assert.doesNotMatch(open, /\sinert=""/); + assert.match(closed, /data-state="closed"/); + assert.match(closed, /\sinert=""/, "links inside a closed panel must stay out of the tab order"); +}); + +test("a closed panel starts collapsed before hydration", () => { + // The accordion-up keyframe animates from --radix-accordion-content-height to + // 0 and falls back to `auto` when the variable is unset, which would play a + // full-height-to-zero collapse on every closed panel as the page loads. + for (const panel of panelTags(render())) { + assert.match(panel, /--radix-accordion-content-height:0px/); + } +});