Two more starters are restart-looping with the same pattern #4123 fixed
for langgraph-*: the 90s (3-strike) watchdog budget is shorter than the
cold-start path on a fresh Railway container, so the agent gets killed
before it ever reports healthy, and the loop never breaks.
Railway evidence:
- claude-sdk-typescript (package, :8000): restart-looping since 04-20
16:54 UTC. Runs compiled `node /app/agent_server.js` which spins up
the full @anthropic-ai/claude-agent-sdk. Package entrypoint is hand-
written (not generator-emitted), so add the same grace block inline
matching #4123's shape.
- mastra (starter, :8123/api): restart-looping since 04-20 18:18 UTC.
Runs `mastra dev` on :8123 alongside Next.js on ${PORT:-10000}.
`mastra dev` performs a tsx build + Mastra server boot on first
request — legitimate supervised process, grace is the right fix.
Package-level mastra has no watchdog (the entire package IS the Next.js
app; no separate agent to watchdog) — PR #4116's classification of
"N/A" was correct for the package. The starter has a separate Mastra
dev server, so the generator-emitted watchdog is legitimate there.
Per-framework grace (not universal) preserves the #4123 design: uvicorn
and express agents are responsive within the 2-3s sleep before
AGENT_HEALTH_CHECK, so adding grace elsewhere would only delay
legitimate restart on true hangs.
This commit covers the generator mapping + the hand-written package
entrypoint. The regenerated starter entrypoints land in the next commit.
## Incident
`langgraph-typescript` is in a restart loop on Railway as of **04-20
17:05 UTC** (deployment `58bbebe8-7a94-4f99-b6e4-ffcbb4eb78b9`),
returning 502 to production traffic.
## Root cause
PR #4116 generalized the silent-hang watchdog from `crewai-crews` to
every showcase starter: poll agent health every 30s, kill after 3
consecutive failures (~90s). That's fine for uvicorn/express agents
(responsive in <5s), but `langgraph-cli dev` does a heavy cold-start:
- Studio browser IPC handshake (`createIpcServer`)
- `@langchain/langgraph-api` JIT spawn (`spawnServer`)
- Graph compile
On cold Railway containers this routinely exceeds 90s — the watchdog was
killing the process before `/ok` ever became reachable, producing a
kill-loop every ~90s.
## Phase 1 findings
Verified the health path is correct — not a path bug:
- `@langchain/langgraph-cli@1.1.17/dist/cli/up.mjs:27` uses `/ok` as its
own probe.
- `@langchain/langgraph-api@1.1.17/dist/api/meta.mjs:64` registers
`api.get("/ok", ...)`.
- `langgraph_cli` (Python) serves `/ok` from the same api surface.
The regression is purely a **timing** issue: 90s strike budget is
insufficient for langgraph cold-start on Railway.
## Fix — Option B (startup grace)
Added per-framework `getWatchdogGraceSeconds()` in the generator:
- `langgraph-*` starters: **180s grace**
- All other starters: **0s grace** (matches pre-#4116 behavior)
The grace loop waits up to 180s for the first healthy `/ok` probe before
arming the strike counter:
- First success → fall through immediately and arm the counter.
- 180s elapsed without success → arm the counter anyway (steady-state
watchdog then handles true hangs on the normal 90s schedule).
- Agent dies during grace → exit grace loop, `wait -n` in main shell
handles it.
**Not a revert of #4116.** The silent-hang vulnerability class remains
covered (still kills after 90s of steady-state failures); the grace only
defers the first strike.
## Per-starter changes
| Starter | Grace | Behavioral change |
|---|---|---|
| langgraph-typescript | 180s | **Fixes restart loop** |
| langgraph-python | 180s | Preemptive |
| langgraph-fastapi | 180s | Preemptive |
| crewai-crews, ag2, agno, claude-sdk-*, google-adk, llamaindex,
langroid, mastra, ms-agent-*, pydantic-ai, spring-ai, strands | 0s |
None (comment-only diff) |
## Files touched
- `showcase/scripts/generate-starters.ts` — new
`getWatchdogGraceSeconds()` + grace block in `getWatchdogBlock()`
- `showcase/starters/*/entrypoint.sh` — regenerated (17 files; grace
block for langgraph-*, comment-only for the other 14)
- `showcase/packages/langgraph-typescript/entrypoint.sh` — hand-edited
grace block (this package uses its own hand-edited entrypoint, not the
template)
- `showcase/packages/langgraph-fastapi/entrypoint.sh` — same
- `showcase/packages/langgraph-python/entrypoint.sh` — same
## Validation
- `bash -n` passes on all 19 edited `entrypoint.sh` files
- 1079/1079 `showcase-scripts` vitest pass (`pnpm --filter
@copilotkit/showcase-scripts test`)
## References
- Regression source: #4116
- Railway deployment: `58bbebe8-7a94-4f99-b6e4-ffcbb4eb78b9`
- Incident window: 04-20 17:05 UTC — ongoing at PR creation
## Test plan
- [ ] CI green
- [ ] Deploy to Railway — watch first boot log for `[watchdog] Startup
grace: waiting up to 180s...` followed by `[watchdog] Agent healthy
after Ns — arming strike counter`
- [ ] Confirm no restart loop on `langgraph-typescript`
PR #4116 introduced a generalized silent-hang watchdog that polls the
agent health endpoint every 30s and kills the agent after 3 consecutive
failures (~90s). This worked for fast-starting agents (uvicorn,
express) but regressed langgraph-typescript on Railway: `langgraph-cli
dev` does a heavy cold-start (Studio IPC setup + @langchain/langgraph-api
JIT spawn + graph compile) that routinely exceeds 90s on a fresh
container, so the watchdog was killing the process before /ok ever
became reachable — producing the 04-20 17:05 UTC restart loop on
deployment 58bbebe8-7a94-4f99-b6e4-ffcbb4eb78b9.
Phase 1 verification:
- `@langchain/langgraph-cli@1.1.17/dist/cli/up.mjs:27` uses /ok as
its own health probe, and `@langchain/langgraph-api@1.1.17/dist/
api/meta.mjs:64` registers `api.get("/ok", ...)` — so the watchdog
path is correct. The regression is purely timing.
- Railway logs show no successful /ok probe before kill-loop starts.
Fix (Option B — startup grace): add a per-framework startup-grace
window in getWatchdogGraceSeconds(). For langgraph-* starters, the
watchdog now waits up to 180s for the first healthy /ok probe before
arming the strike counter. If /ok comes up sooner, fall through
immediately. If 180s elapses without success, arm the counter anyway —
the steady-state watchdog will then handle a true hang on the normal
90s schedule.
Non-langgraph starters (crewai-crews, ag2, agno, etc.) have grace=0 and
behave exactly as before PR #4116 — the 2–3s `sleep` before the health
check is sufficient for uvicorn-based agents.
Files changed:
- showcase/scripts/generate-starters.ts — new getWatchdogGraceSeconds()
+ grace block in getWatchdogBlock()
- showcase/starters/*/entrypoint.sh — regenerated (grace block for
langgraph-*, comment-only for all others)
- showcase/packages/langgraph-typescript/entrypoint.sh — hand-edited
grace block (this package uses its own hand-edited entrypoint, not
the template)
- showcase/packages/langgraph-fastapi/entrypoint.sh — same
- showcase/packages/langgraph-python/entrypoint.sh — same
Not a revert of #4116: the silent-hang vulnerability class remains
covered (the watchdog still kills after 90s of steady-state failures;
the grace only defers the first strike).
Validated:
- bash -n on all 19 edited entrypoint.sh files
- 1079/1079 showcase-scripts vitest pass
Regression from the 2026-04-21 incident: 18 production Railway services
were found with malformed image refs of the form
`ghcr.io/copilotkit/showcase-<slug>atest` (missing the `:` before
`latest`, so Docker treats `...atest` as the tag). Root cause was an
out-of-band MCP/manual mutation — no committed code touched those refs,
so the data has been fixed but no source-controlled guardrail exists.
Add a standalone script that queries Railway's GraphQL API for every
service in the CopilotKit Showcase project and asserts each image ref
matches the canonical shape `ghcr.io/copilotkit/<service-name>:latest`.
Wire it into showcase_deploy.yml as a pre-build job so any drift aborts
the workflow before the build matrix fans out.
On violation the script prints the service name, the current image, the
expected shape, and the reason, so the fix is obvious in the run log.
Slack classification in the notify job distinguishes a drift failure
from other pre-build failures.
Verified locally: 41 services pass against current Railway state; the
exported `validateImage` function rejects the exact `...atest`
corruption, mismatched service/image names, missing tags, wrong
registries, wrong tag values, and null sources (9/9 simulated cases).
Hand-applied watchdog + unbuffered-stdout block for every Bucket-B package
entrypoint where the shape couldn't come from the starter generator:
* ag2, agno, claude-sdk-python, claude-sdk-typescript, google-adk,
langgraph-fastapi, langgraph-python, langgraph-typescript, langroid,
llamaindex, ms-agent-dotnet, ms-agent-python, pydantic-ai, strands
-> watchdog polls the framework-native health endpoint; Python variants
also get `python -u` + awk fflush log prefixing.
* spring-ai (Bucket C) -> watchdog extends the existing startup probe;
polls :8000/health every 30s and kills PID on 3 consecutive failures.
Added WATCHDOG_PID cleanup to the post-wait block.
* mastra is single-`exec next start` on the package side — no agent
process to probe, so no watchdog needed (N/A).
Reference shape: showcase/packages/crewai-crews/entrypoint.sh (unchanged
from origin/main; proven in production via PRs #4114 + #4115).
Regenerates all 16 starter entrypoint.sh files from the new template. Every
starter now ships:
- $WATCHDOG_PID tracked in cleanup + kill block
- PYTHONUNBUFFERED=1 export (harmless for Java/Node/.NET)
- Explicit `wait -n $AGENT_PID $NEXTJS_PID` (excludes the watchdog so its
exit doesn't short-circuit the agent's true status)
- Per-framework health probe (see prior commit for path matrix)
Also reflects the crewai-crews root-cause fix (already on origin/main via
commit 9379b8855): ag-ui-crewai >=0.2.0 bump and drop of the crewai.cli
monkey-patch shim. These files are regenerated from showcase/packages/
crewai-crews source.
Generalize the watchdog shape proven in showcase/packages/crewai-crews/entrypoint.sh
(PRs #4114 + #4115) so that every Bucket-B starter gets:
- PYTHONUNBUFFERED=1 export (harmless for non-Python frameworks)
- A backgrounded watchdog subshell that polls the agent health endpoint
every 30s and kills the agent after 3 consecutive failures, letting
wait -n + container runtime handle the restart through the normal path.
- python -u on uvicorn / langgraph_cli invocations (Python frameworks).
- awk ... fflush() log prefixing (replaces the previous sed pipe; keeps $!
pointing at the real agent process).
Per-framework agent health paths:
* FastAPI / uvicorn agents -> /health
* langgraph-python, langgraph-fastapi, langgraph-typescript -> /ok
* claude-sdk-typescript, ms-agent-dotnet, spring-ai -> /health
* mastra -> /api
Spring Boot starter also gets a 60s /health startup probe (replacing the
blind sleep 5) because JVM warmup + context refresh can exceed 30s.
Root-cause fix for the 04-21 silent-hang incident on the crewai-crews
Railway deploy. Three tightly-coupled changes:
1. Bump ag-ui-crewai pin from `>=0.1.4,<0.1.6` to `>=0.2.0,<0.3.0`.
0.1.5 had three defects that wedged the agent: unguarded
`source.state.messages` access, an orphan `asyncio.create_task` with
no cancel ref, and a sync `completion()` call that pinned the event
loop. All three are fixed in ag-ui PR #1550, released as 0.2.0 on
2026-04-18. Our upper ceiling was blocking the fix.
2. Remove the pre-bind LLM crash hardening shim in `agent_server.py`.
The shim monkey-patched `crewai.cli.crew_chat.generate_*_description_with_ai`
to static strings so that `ChatWithCrewFlow.__init__` — which
ag-ui-crewai <= 0.1.5 invoked at endpoint-registration time, BEFORE
uvicorn bound its port — could not crash the process before the HTTP
server was listening. 0.2.0 defers `ChatWithCrewFlow` construction to
first request via a module-scoped `_cached_flow` + `asyncio.Lock`
inside `add_crewai_crew_fastapi_endpoint`. Any LLM hiccup now
surfaces as a 5xx on the first request instead of a startup crash,
which is what the shim was reaching for. The shim is dead code on
0.2.0 and has been removed (with `logging` import dropped as it was
only used by the shim).
3. Add `python -u` to the uvicorn invocation in `entrypoint.sh` as a
belt-and-suspenders complement to the existing `PYTHONUNBUFFERED=1`
export. The env var can in principle be un-exported by a child;
`-u` forces unbuffered stdout/stderr at the interpreter level and
is not overridable by user code. Combined with `awk '{...; fflush()}'`
in the pipe (already in place), this guarantees uvicorn request
lines reach Railway's log stream line-at-a-time. During the 04-21
incident Railway saw only ~15 log lines over 9h of uptime because
of buffering through a previous `sed` formulation.
Also updates `showcase/scripts/fail-baseline.json`'s `validatePinsFailHash`
to match the new `ag-ui-crewai` spec string. Pin-drift FAIL count is
unchanged (110); the hash changed only because the `ag-ui-crewai` line
in the FAIL set went from `>=0.1.4,<0.1.6` to `>=0.2.0,<0.3.0`.
Verified locally:
- `pip install -r requirements.txt` resolves `ag-ui-crewai-0.2.0` cleanly.
- `python -u -m uvicorn agent_server:app` starts; `/health` returns
200 `{"status":"ok"}`; request lines appear in real-time logs.
- `pytest tests/python/` — 94/94 pass.
- `pnpm -C showcase/scripts test` (vitest) — 1079/1079 pass.
- `validate-pins.ts` — count=110 matches baseline; hash updated.
Upstream refs:
- crewAI issue: https://github.com/crewAIInc/crewAI/issues/5510
- ag-ui PR #1550: https://github.com/ag-ui-protocol/ag-ui/pull/1550
Intentionally NOT in this PR:
- `showcase/starters/crewai-crews/` parity backport (the starter still
carries the 0.1.5 pin and the shim).
- The 14-starter watchdog generalisation.
Both belong to the silent-hang vulnerability-class work tracked
separately.
Production deploy of showcase-crewai-crews was stuck in agent:"down" for
~4-5h on 2026-04-21. Railway deployment logs show the FastAPI agent on
:8000 was reachable at startup and handled hundreds of requests, then
stopped responding at ~06:56-07:01 UTC after an
unhandledRejection: UND_ERR_BODY_TIMEOUT from the Next.js runtime. The
Python process did not exit — it hung — so bash's wait -n never fired,
the container never restarted, and every subsequent request stacked up
on undici's default 5-minute headers timeout (UND_ERR_HEADERS_TIMEOUT).
Two surgical fixes:
1. entrypoint.sh: add a watchdog goroutine that polls
http://127.0.0.1:8000/health every 30s with a 5s curl timeout. After
3 consecutive failures (~90s unreachable) it SIGKILLs the agent PID,
causing wait -n to return and the container to restart. This converts
a silent agent hang into a fast, loggable restart. Also adds structured
[entrypoint] / [agent] / [nextjs] / [watchdog] log prefixes, a startup
PID-is-alive check, and PYTHONUNBUFFERED=1 so crashes surface before
the log pipe closes — mirroring the starter entrypoint pattern.
2. src/app/api/smoke/route.ts: split the upstream fetch timeout
(UPSTREAM_TIMEOUT_MS = 25_000) from the route's overall budget
(maxDuration = 60). Previously the inner fetch had AbortSignal.timeout(45000)
which, on a hung agent, burned nearly all of the 60s before Next.js
killed the request — producing HTTP 000 at the caller instead of a
structured stage: "timeout" JSON response. With the shorter inner
timeout the route can report a clean timeout within ~25s even when
the agent is wedged.
Both fixes are defensive against upstream CrewAI ChatWithCrewFlow
deadlocks (which the logs show happening repeatedly via the pre-existing
"Cannot send 'RUN_FINISHED' while steps are still active" errors). The
watchdog is scoped to this package only; the starter already has a
richer entrypoint with similar intent.
Directory-plus-index.mdx layout produced two reference entries for
the same logical page: one at `foo/` (the index) and one at `foo`
(a flat sibling if it existed). Collapse index.mdx into the parent
slug so the generated list has a single canonical entry, and bail
out with a clear error when a flat-file collision would shadow the
index. Prevents silently dropping one of the two pages at build
time.
Correctness and portability fixes in the demo-content bundler:
- Track contributor snippets per-file so edits to multiple files in
one commit all get attributed, not just the last one walked.
- Extend endLine when the same file is seen again rather than
dropping the earlier slice — previously a later, smaller region
overwrote a larger one.
- Warn when the watch flag is set on Linux without the recursive
fs.watch support matrix, so the user sees why nothing is firing
instead of assuming silent success.
- Normalize path separators for Windows so bundle manifests use
POSIX paths regardless of the host OS.
The log effect depended on the error object identity, which changes
on every render even when the underlying error is the same — so the
effect fired repeatedly and spammed the log. Depend on
error.message and error.digest (primitive, stable across renders
of the same error) instead. React's exhaustive-deps check is still
satisfied because those are the fields the effect actually reads.
A cluster of UX correctness fixes across the page handlers and
the shared brand nav:
- Filter out undeployed frameworks before rendering so the route
doesn't produce blank pages that users can reach via stale links.
- Strip the leading body H1 with a CRLF-safe regex that only matches
when the body H1 equals the frontmatter title — mirrors ag-ui
route behavior so two stacked titles never render.
- Reference routes now titleCase the slug and resolve via the
index.mdx fallback, matching the directory-plus-index layout the
docs source uses.
- ag-ui title resolver gains a fallback so deep slugs without a
matching registry entry still produce a reasonable title instead
of crashing.
- brand-nav builds the mobile link href dynamically so it points at
the current framework rather than a hard-coded placeholder.
The selector dropdown previously rendered every framework entry as
a clickable option, including ones that had been marked as not
deployed. Selecting an undeployed entry routed to a blank page.
Disable the button for those entries so the dropdown matches the
surrounding tab behavior, which already hides them.
localStorage can hold a value from a previous deploy that no longer
exists in the current frameworks list (package renames, undeploy,
etc.), which pins the provider to a bogus framework identity until
the user manually picks another. Validate the stored value against
the current list both at mount and on cross-tab `storage` events,
and fall back to the default when it doesn't match.
Framework-tabs assumed the items array always matched the frameworks
prop one-to-one. A count mismatch wedged the active index at a stale
value that could fall outside the new frameworks array, rendering a
blank tab. Add a length-mismatch guard that re-syncs state when the
frameworks prop changes between renders, and remove props that were
threaded through but never read so the surface matches what the
component actually uses.
When the tab item array contains duplicate values or has a length
that doesn't match the items prop, looking up the active index by
value conflates tabs. Find the active tab by position instead so
each rendered tab has a stable identity regardless of duplicated
or drifted values.
The img and video overrides spread props, then replaced style wholesale
with the layout defaults — silently discarding any author-provided
style keys from MDX. Merge author style first and layout defaults
last so we preserve author intent (e.g. custom max-width, filters)
while still enforcing our rounded-corner + marginBottom guards.
Per MDN, combining `allow-scripts` with `allow-same-origin` lets the
framed page remove its own sandbox attribute at runtime — that is a
sandbox escape regardless of how much we trust the origin. Drop
`allow-same-origin` from all three iframe sandboxes (InlineDemo,
author-supplied IFrame, YouTube embed). None of them need same-origin
semantics with the parent docs host to function.
Also validate InlineDemo's computed demo URL at the sink instead of
trusting the registry blindly — a malformed backend_url should render
a visible error placeholder, not a silently broken iframe.
MDX-authored hrefs flow into <a> and <Link> without scheme filtering.
`rel="noopener noreferrer"` does not neutralize script-URL schemes
like `javascript:`, `data:text/html`, or `vbscript:`, so a malicious
snippet could land in the rendered page as an XSS vector.
Add sanitizeHref() with an allowlist of http/https/mailto/tel plus
relative / protocol-relative / fragment / query forms. Anything else
returns null and the A/Link overrides render a <span> with the same
visible content instead of an anchor. Also add a protocol-relative
(`//`) early-return in isExternalHref so those URLs correctly route
through a plain <a> rather than next/link.
The title resolution path had two correctness bugs:
- readTitle and loadDoc both ran the H1 fallback regex against the
raw file, so a YAML comment like `# ...` inside frontmatter could
be picked up as the page's H1. Run the regex against the parsed
body (frontmatter stripped) instead. Also log a console.error when
frontmatter parsing blows up so a malformed file doesn't silently
yield a garbage title.
- docs-page-view unconditionally stripped the leading body H1, which
dropped distinct H1 headings whenever the MDX body didn't match
the frontmatter title. Mirror the ag-ui route: only strip when the
body H1 equals the FM title after whitespace normalization, and
use `\r?\n` so CRLF-authored MDX parses correctly too.
MDX authors pass component-map overrides via doubled-brace object
syntax, e.g. `<SharedContent components={{ Foo: Bar }} />`. The
previous regex used `[^}]*` which truncates at the inner `}`, so
the tag never matched and the snippet was never inlined. Match the
doubled-brace object form explicitly with `\{\{...\}\}` and
`[\s\S]*?` so multi-line prop objects still match.
JS regex alternation is leftmost-first, not leftmost-longest, so for
source like `<Tabs>` the pattern `Tab|Tabs` matches "Tab", `[^>]*`
consumes the "s", and the close-side alternation would then pair
with any container's `</...>` regardless of which tag opened. Two
separate bugs fell out of this:
- Prefix collisions (Tab vs Tabs) silently skipped table conversion.
Sort JSX_CONTAINER_TAGS longest-first so the opener always matches
the actual outer tag name.
- Mismatched pairs like `<Tabs><Tab>x</Tab></Tabs>` paired the outer
`<Tabs>` with the inner `</Tab>`, stranding `</Tabs>`. Use a
numbered backref (`\\2`) on the closing tag so only the same tag
name can close the match.
Also tighten the inline comment on the nested-same-tag bailout to
reflect that the non-greedy match now closes on the same tag, not
"any container in the set".
- bundle-demo-content: reject highlight: paths that resolve outside
the package root. The bundle output is committed to the repo and
consumed by both shells at build time, so a malicious or
mistake-riddled manifest could otherwise smuggle arbitrary
filesystem contents (../../secrets, absolute paths) into
demo-content.json. Resolve relative to pkgRoot and throw on
escape (finding #19).
- generate-registry: add a runtime guard that manifest.slug is a
non-empty string before path.join(PACKAGES_DIR, manifest.slug).
Schema validation upstream already enforces this, but a
silently-undefined slug fed to path.join yields
"<packages-dir>/undefined" and would produce an empty docs_links
without surfacing any error. Fail loudly instead (finding #20).
Extract the near-identical card markup from both route-level
error.tsx files into src/components/error-boundary-card.tsx. The
route-level handlers remain required (Next.js discovers them per
route segment) but are now one-line wrappers that forward props
with a scope label ("docs" / "ag-ui") so console.error entries
stay distinguishable per route segment (finding #14).
- Replace the className="capitalize" breadcrumb span (which only
cased the first character, rendering my-component as
My-component) with an explicit titleCase helper that splits on
hyphens and capitalizes each segment (finding #12).
- Derive the sidebar category list from loadAllReferenceItems
instead of hardcoding ["Components", "Hooks"]. A new
REFERENCE_SUBDIRS entry in reference-items.ts now shows up
automatically without a second edit here (finding #13).
- titleFromSlug now Title-Cases the result (multimodal-inputs →
Multimodal Inputs) instead of returning a lowercased fallback.
The lowercase labels previously clashed with the docs-render
and reference-breadcrumb conventions elsewhere (finding #10).
- Wrap getTitleForSlug in a process-scoped cache with dev-mode
invalidation, mirroring reference-items.ts. Previously every
entry in NAV_DEFINITION re-opened and re-parsed its MDX file on
every request — multiply across ~20 nav entries per render and
it added noticeable fs churn (finding #11).
- Reject RESERVED_ROUTE_SLUGS at the handler as defense-in-depth.
Next.js already prefers exact-match top-level routes over this
catch-all, so /docs, /ag-ui, etc. never reach here under normal
routing — but if the registry ever ships an integration whose slug
collides with a reserved segment, the handler now short-circuits
with notFound() rather than rendering garbage (finding #6).
- Import the shared findFrameworksWithCell helper instead of a
duplicated local copy (finding #3 follow-through).
- Filter out unresolved alternative-framework slugs BEFORE mapping
to React fragments so the ", " separator aligns with the final
rendered count. The pre-filter index previously emitted stray
commas when an entry dropped to null mid-sequence (finding #7).
- FrameworkLandingPage's four hardcoded LandingCards are now gated by
registry integration.features presence: strands (no HITL support)
no longer shows a dead "Human-in-the-Loop" card. The card
definitions are hoisted into a module-level constant with a TODO
pointing at the eventual feature-id → doc-slug mapping
(finding #8).
- Declare export const dynamicParams = true explicitly so a future
migration to output: "export" fails loudly here (under static
export dynamicParams must be false) rather than silently 404ing
every /<framework>/<slug> URL (finding #9).
- Extract findFrameworksWithCell into @/lib/docs-render so the docs
catch-all and framework-scoped catch-all share one implementation
instead of carrying a near-identical local copy in each (finding #3).
- Validate /docs/integrations/<slug> against the registry with
notFound() when the slug is unknown. Previously a crafted URL like
/docs/integrations/fake-framework silently fell through to an empty
nav tree, indistinguishable from a valid integration with no scoped
content (finding #4).
- Sort integrations by sort_order then slug before picking the
animated preview URL. Registry iteration order alone isn't
deterministic w.r.t. the visual priority the docs UI shows
everywhere else, so the preview now matches (finding #5).
Two additions in snippet.tsx:
1. parseLineRange now accepts comma-separated segments like
`lines="1-5,10-15"`. Each segment is validated and sliced
independently; discontinuous sections are stitched with a visible
`// ...` gap marker so readers see the jump. Invalid segments fail
the whole prop (clear error beats partial rendering). Single-range
and open-ended ("A-") forms continue to work unchanged.
2. resolveHljsLanguage now has explicit entries for tsx (→ typescript),
jsx (→ javascript), and sh / bash / shell (→ bash). These are the
common bundler-emitted hints that previously returned null and
fell through to highlightAuto — producing noisy one-shot warnings
and non-deterministic highlighting.
The __itemsCache has no invalidation hook and lives for the life of
the Node process. That's correct for the current `next start`
deployment but silently fragile if we ever add ISR to /reference —
the cache would serve stale items forever.
Replace the two-line inline comment with a JSDoc block that spells
out the prod vs dev behaviour and the ISR caveat, so future changes
know where the assumption lives.
Three loosely-coupled shim hardenings in one commit (all in mdx-registry.tsx):
- Tooltip / TooltipProvider were children-only shims that silently
dropped Radix-style `content` / `label` props. Route through
stub() so dev gets a one-shot warning with the dropped prop names.
- YouTubeVideo + IframeSwitcher now validate author-controlled URLs
via a shared validateIframeSrc() helper: only https:// is accepted,
malformed URLs fall back to rendering nothing, and dev gets a
targeted warning. YouTubeVideo also enforces the 11-char video-id
shape so a stray value can't traversal-inject query params into the
embed URL.
- Link shim now detects external hrefs (https://..., mailto:..., etc.)
and routes them through a plain <a> with sensible defaults
(target=_blank, rel=noopener noreferrer). Internal hrefs continue to
use next/link for client-side navigation. next/link should never be
used for external URLs — it spuriously prefetches them.
The inlineSnippets regex deliberately only matches strict self-closing
JSX with an optional `components={...}` attr, which confused readers
into thinking tags like `<Snippet region="x" />` fall through by
accident. Document the two-path design (SNIPPET_MAP inlining vs MDX
component map) so future edits don't try to "fix" the selectivity.
No behaviour change. SNIPPET_MAP aliases were already documented
in-place (finding noted earlier); this commit covers the regex comment
only.
readTitle/readMeta are backed by process-scoped Maps. Without a dev
bypass, editing an MDX title or meta.json required a server restart to
see the change — the nav sidebar would keep showing the old title.
Match the convention already used in reference-items.ts: gate both the
read and write paths on isProd() so `next dev` re-reads the source on
every request. Production behaviour is unchanged.
Both props were accepted but ignored (underscore-destructured). scope
was originally intended to switch resolution logic, but both branches
collapsed to the same href rule. fallbackHref was a pre-hydration
placeholder that became unnecessary once the provider made framework
URL-derived.
Remove them from the public SidebarLinkProps and from the only caller
(docs-page-view), which was computing a scope based on slugHrefPrefix
solely to pass it through.
cell and region are authored on the interface for MDX usage + tooling
but the component doesn't consume them at runtime — the parent MDX
renderer pre-renders one <Snippet> per framework on the server and
emits them as children. Mark them optional and document the contract.
Also filter React.Children.toArray output to valid elements before the
index→framework map, so stray whitespace text nodes injected by MDX
don't shift the mapping and render the wrong snippet.
iOS Safari does not reliably fire mousedown for taps landing outside
focusable UI, so the panel stayed open on mobile. Mirror the existing
click-outside handler on touchstart and widen the handler type to
MouseEvent | TouchEvent. stripFrameworkPrefix already restricts its
match to the first path segment (docstring makes this explicit).
- Dev-only console.warn when author-supplied items.length diverges from
the number of <Tab> children — silent drop of extra entries hid bugs.
- Dev-only console.warn on duplicate labels — React keys on label below
so duplicates broke reconciliation.
- Tab button keys now include the index so duplicate labels don't cause
React key collisions.
- Added a useEffect that re-seeds the active tab when the set of labels
changes (e.g. MDX edit swaps titles, HMR reload). Previously useState
initialized once on mount, so a tab whose label disappeared left the
panel stuck empty.
Previously any valid child element received the injected __index/__total
props — stray non-Step valid elements (e.g. spacer divs) would have those
props passed straight to the DOM, triggering React warnings. Now filter
to elements whose type === Step before cloning.
Also add role='list' to the Steps container and role='listitem' to each
Step so assistive tech can enumerate the sequence.
Silent fallback to 'info' masked typos in MDX authors' type props. Now
emit a dev-only console.warn listing the known types when an unknown
one slips past TypeScript (e.g. from raw MDX string literals).
- activeBrandFromPath now matches /ag-ui exactly or /ag-ui/..., not any
path starting with those six chars — prevents misclassifying a future
/ag-ui-* slug as AG-UI.
- Mobile menu panel z-index bumped to z-[51] so layering above the z-50
backdrop doesn't rely on DOM sibling order.
- BrandNavProps (frameworkOptions, frameworkCategoryOrder) were unused
dead API — removed from the interface and function signature. Call
site passes no props, so no caller changes.
React.Children.map only visits *direct* children. When an author
wraps nested <PropertyReference> in a <div> or <Fragment> (a common
MDX pattern), the nested references silently lost `collapsable:
true` propagation because the walk stopped at the wrapper.
Replace the single-level `React.Children.map` with
`deepMapPropertyReferences`, a recursive helper that:
- clones + enhances any PropertyReference at any depth with
`collapsable: true`
- preserves arbitrary wrapper elements (div, Fragment, other
components) and recurses into their children
- stops recursing once it hits a PropertyReference — that
reference's own render will deep-walk ITS children on its turn,
which is the correct nesting semantic
Updates the JSDoc above the enhanced-children block to reflect the
new behavior — nested wrappers are now supported.
Both FrameworkGuardedContent and RouterPivot used to render against
`storedFramework` on the very first client render, when it is always
null (localStorage is read in a mount effect inside
FrameworkProvider). A returning user who picked LangChain would
briefly see the full pivot grid + MDX body flash in before
storedFramework flipped from null to "langgraph-python" and the
redirect fired.
Gate both components behind a local `hasHydrated` flag flipped in a
mount useEffect. Pre-hydration we render null (for the MDX body) or
a minimal "Loading…" placeholder (for the pivot itself), so
returning users go straight to the redirect placeholder instead of
flashing content that's about to disappear. Fresh visitors see the
pivot on the next tick — imperceptible in practice.