From e3505e56910a78c519131c93d68d2223096dac27 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 19 Aug 2026 21:52:21 +0300 Subject: [PATCH 1/3] Scope Nedi CDN assets to the Ask Nedi route The Nedi stylesheet and its seven script dependencies were declared in the site-wide `stylesheets`/`scripts` head, so every page on learn downloaded about 1.5 MB compressed (5.15 MB uncompressed) of JavaScript plus a render-blocking cross-origin stylesheet that only /docs/ask-nedi uses. src/components/Nedi/assets.js now injects them imperatively when the Ask Nedi component mounts: the stylesheet link first, then the scripts in declaration order with `async = false` so markdown-it is evaluated before the embed that waits for it. Version-pinned jsDelivr URLs carry Subresource Integrity hashes and `crossorigin=anonymous`; the endpoint's own bundles are redeployed in place behind a four-hour cache, so pinning a hash there would reject the asset after the next endpoint release. The component polls for `window.AiAgentChatUI` and markdown-it, gives up after 15 seconds or on an asset error, and renders a retry control that discards the failed injection and re-injects. It also sets the embed source identifier before injection instead of at module evaluation. theme-mermaid still lazy-loads mermaid 11.16.1 for pages with a diagram, and the Cloudflare beacon and Reo entries are unchanged. --- docusaurus.config.js | 13 +- src/components/Nedi/assets.js | 102 ++++++++++++ src/components/Nedi/assets.test.js | 141 ++++++++++++++++ src/components/Nedi/index.js | 184 +++++++++++++-------- src/components/Nedi/index.test.js | 250 +++++++++++++++++++++++++++++ 5 files changed, 612 insertions(+), 78 deletions(-) create mode 100644 src/components/Nedi/assets.js create mode 100644 src/components/Nedi/assets.test.js create mode 100644 src/components/Nedi/index.test.js diff --git a/docusaurus.config.js b/docusaurus.config.js index 858e791b1..449299110 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -230,8 +230,6 @@ module.exports = { ], ], stylesheets: [ - // Nedi embed styles - 'https://nedi.netdata.cloud/ai-agent-ui.css?v=19', { href: '/font/ibm-plex-sans-v8-latin-regular.woff2', rel: 'preload', @@ -273,15 +271,8 @@ module.exports = { defer: true, 'data-reo-client-id': '8a197d1119ef2d4', }, - // Nedi dependencies (CDN) - async to avoid blocking other scripts - { src: 'https://cdn.jsdelivr.net/npm/markdown-it@15.0.0/dist/browser/markdown-it.umd.min.js', async: true }, - { src: 'https://cdn.jsdelivr.net/npm/mermaid@11.16.1/dist/mermaid.min.js', async: true }, - { src: 'https://cdn.jsdelivr.net/npm/@viz-js/viz@3.29.0/dist/viz-global.js', async: true }, - { src: 'https://cdn.jsdelivr.net/npm/turndown@7.2.4/dist/turndown.js', async: true }, - { src: 'https://cdn.jsdelivr.net/npm/@guyplusplus/turndown-plugin-gfm@1.0.7/dist/turndown-plugin-gfm.js', async: true }, - // Nedi embed - { src: 'https://nedi.netdata.cloud/ai-agent-public.js?v=19', async: true }, - { src: 'https://nedi.netdata.cloud/ai-agent-ui.js?v=19', async: true }, + // The Nedi embed, its stylesheet and its CDN dependencies are route-scoped: + // src/components/Nedi/assets.js injects them on the Ask Nedi page only. ], headTags: [ { diff --git a/src/components/Nedi/assets.js b/src/components/Nedi/assets.js new file mode 100644 index 000000000..0d5194777 --- /dev/null +++ b/src/components/Nedi/assets.js @@ -0,0 +1,102 @@ +import { ensureMarkdownItCompatibility } from './markdownItCompatibility'; + +export const NEDI_ENDPOINT = 'https://nedi.netdata.cloud'; + +// The embed and its dependencies are only useful on the Ask Nedi route, so they are +// injected from this component instead of being declared in the site-wide head. +// +// jsDelivr paths are version-pinned and immutable, so they carry Subresource Integrity +// hashes. The endpoint's own bundles are redeployed in place behind a short cache, so a +// pinned hash there would reject the asset after the next endpoint release. +export const NEDI_ASSETS = [ + { type: 'css', src: `${NEDI_ENDPOINT}/ai-agent-ui.css?v=19` }, + { + type: 'js', + src: 'https://cdn.jsdelivr.net/npm/markdown-it@15.0.0/dist/browser/markdown-it.umd.min.js', + integrity: 'sha384-RFgiWKVXntFwXKC7cM/vTNo+YCPWtoe5WjzaQWX8NMxIt3CnW1Sjhgt6YPVyrGT5', + }, + { + type: 'js', + src: 'https://cdn.jsdelivr.net/npm/mermaid@11.16.1/dist/mermaid.min.js', + integrity: 'sha384-aBQXj4hK6Jm05i7aQAsUV3bLdSUrHX1BGYfMB0166TtWt/RRaw+h0Eelme9OCOvy', + }, + { + type: 'js', + src: 'https://cdn.jsdelivr.net/npm/@viz-js/viz@3.29.0/dist/viz-global.js', + integrity: 'sha384-39ZxW8vr+xPchaaptsOWpdQjpckcdy40zkLeHLA4Yv3x0el06s2iBnWQ/s/ppFXQ', + }, + { + type: 'js', + src: 'https://cdn.jsdelivr.net/npm/turndown@7.2.4/dist/turndown.js', + integrity: 'sha384-VRHmZZ8b5mH5yknWcg48OJS6RmXZmlgvsqhOXJqY0rwvwirs1M12xd+49c3NpW6a', + }, + { + type: 'js', + src: 'https://cdn.jsdelivr.net/npm/@guyplusplus/turndown-plugin-gfm@1.0.7/dist/turndown-plugin-gfm.js', + integrity: 'sha384-b4AQtiEmaWubq+mFwFnRJCHtHL9HxF2bSrT67eGId8giR2orvUqJFaLQN5NsUP89', + }, + { type: 'js', src: `${NEDI_ENDPOINT}/ai-agent-public.js?v=19` }, + { type: 'js', src: `${NEDI_ENDPOINT}/ai-agent-ui.js?v=19` }, +]; + +let injected = []; +let loadFailed = false; + +export const nediDependenciesReady = () => + typeof window !== 'undefined' && + typeof window.AiAgentChatUI !== 'undefined' && + ensureMarkdownItCompatibility(window); + +export const nediAssetsFailed = () => loadFailed; + +function createAssetElement(asset) { + if (asset.type === 'css') { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = asset.src; + return link; + } + + const script = document.createElement('script'); + script.src = asset.src; + // Dynamically inserted scripts default to async; the embed expects markdown-it first. + script.async = false; + if (asset.integrity) { + script.setAttribute('integrity', asset.integrity); + script.setAttribute('crossorigin', 'anonymous'); + } + return script; +} + +function removeNediAssets() { + injected.forEach((element) => { + element.onerror = null; + element.remove(); + }); + injected = []; + loadFailed = false; +} + +export function loadNediAssets() { + if (injected.length || nediDependenciesReady()) return; + + // The embed reports this identifier with every conversation it starts. + window.AI_AGENT_UI_SOURCE = 'learn'; + + const onError = () => { + loadFailed = true; + }; + + NEDI_ASSETS.forEach((asset) => { + const element = createAssetElement(asset); + element.onerror = onError; + document.head.appendChild(element); + injected.push(element); + }); +} + +// Discards a failed injection so the next load starts from a clean head. +export function reloadNediAssets() { + removeNediAssets(); + loadNediAssets(); +} diff --git a/src/components/Nedi/assets.test.js b/src/components/Nedi/assets.test.js new file mode 100644 index 000000000..914324a33 --- /dev/null +++ b/src/components/Nedi/assets.test.js @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const freshAssets = async () => { + vi.resetModules(); + return import('./assets'); +}; + +const injectedElements = () => [...document.head.querySelectorAll('link, script')]; +const injectedScripts = () => [...document.head.querySelectorAll('script')]; + +const stubMarkdownIt = () => { + const markdownIt = () => ({ linkify: { set: () => {} } }); + window.markdownit = markdownIt; +}; + +const stubEmbed = () => { + window.AiAgentChatUI = function AiAgentChatUI() {}; +}; + +describe('Nedi asset loader', () => { + beforeEach(() => { + injectedElements().forEach((element) => element.remove()); + delete window.AiAgentChatUI; + delete window.markdownit; + delete window.AI_AGENT_UI_SOURCE; + }); + + it('injects the stylesheet first and then every script in declaration order', async () => { + const { NEDI_ASSETS, loadNediAssets } = await freshAssets(); + + loadNediAssets(); + + expect(injectedElements().map((element) => element.tagName)).toEqual([ + 'LINK', + ...NEDI_ASSETS.slice(1).map(() => 'SCRIPT'), + ]); + + const [link] = injectedElements(); + expect(link.rel).toBe('stylesheet'); + expect(link.getAttribute('href')).toBe(NEDI_ASSETS[0].src); + expect(injectedScripts().map((script) => script.getAttribute('src'))).toEqual( + NEDI_ASSETS.filter((asset) => asset.type === 'js').map((asset) => asset.src), + ); + }); + + it('keeps script execution ordered instead of the dynamic-insertion default', async () => { + const { loadNediAssets } = await freshAssets(); + + loadNediAssets(); + + expect(injectedScripts().every((script) => script.async === false)).toBe(true); + }); + + it('pins integrity only for the version-locked CDN dependencies', async () => { + const { NEDI_ASSETS, loadNediAssets } = await freshAssets(); + + loadNediAssets(); + + const scripts = injectedScripts(); + NEDI_ASSETS.filter((asset) => asset.type === 'js').forEach((asset, index) => { + const script = scripts[index]; + if (asset.src.startsWith('https://cdn.jsdelivr.net/')) { + expect(asset.integrity).toMatch(/^sha384-/); + expect(script.getAttribute('integrity')).toBe(asset.integrity); + expect(script.getAttribute('crossorigin')).toBe('anonymous'); + } else { + expect(asset.integrity).toBeUndefined(); + expect(script.hasAttribute('integrity')).toBe(false); + expect(script.hasAttribute('crossorigin')).toBe(false); + } + }); + }); + + it('identifies the embed source before the embed script runs', async () => { + const { loadNediAssets } = await freshAssets(); + + loadNediAssets(); + + expect(window.AI_AGENT_UI_SOURCE).toBe('learn'); + }); + + it('injects at most one copy of each asset', async () => { + const { NEDI_ASSETS, loadNediAssets } = await freshAssets(); + + loadNediAssets(); + loadNediAssets(); + + expect(injectedElements()).toHaveLength(NEDI_ASSETS.length); + }); + + it('skips injection when the dependencies are already usable', async () => { + const { loadNediAssets, nediDependenciesReady } = await freshAssets(); + stubEmbed(); + stubMarkdownIt(); + + expect(nediDependenciesReady()).toBe(true); + loadNediAssets(); + + expect(injectedElements()).toHaveLength(0); + expect(window.AI_AGENT_UI_SOURCE).toBeUndefined(); + }); + + it('reports dependencies as unusable until both the embed and markdown-it exist', async () => { + const { nediDependenciesReady } = await freshAssets(); + + expect(nediDependenciesReady()).toBe(false); + + stubEmbed(); + expect(nediDependenciesReady()).toBe(false); + + stubMarkdownIt(); + expect(nediDependenciesReady()).toBe(true); + }); + + it('records a failure when any asset fails to load', async () => { + const { loadNediAssets, nediAssetsFailed } = await freshAssets(); + + loadNediAssets(); + expect(nediAssetsFailed()).toBe(false); + + injectedScripts()[0].dispatchEvent(new Event('error')); + expect(nediAssetsFailed()).toBe(true); + }); + + it('replaces a failed injection with fresh elements on reload', async () => { + const { NEDI_ASSETS, loadNediAssets, reloadNediAssets, nediAssetsFailed } = + await freshAssets(); + + loadNediAssets(); + const first = injectedElements(); + first[0].dispatchEvent(new Event('error')); + + reloadNediAssets(); + const second = injectedElements(); + + expect(nediAssetsFailed()).toBe(false); + expect(second).toHaveLength(NEDI_ASSETS.length); + expect(second.some((element) => first.includes(element))).toBe(false); + expect(first.every((element) => element.isConnected === false)).toBe(true); + }); +}); diff --git a/src/components/Nedi/index.js b/src/components/Nedi/index.js index d93d5286b..7443ac9cf 100644 --- a/src/components/Nedi/index.js +++ b/src/components/Nedi/index.js @@ -1,11 +1,18 @@ -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useState, useCallback } from 'react'; import { useColorMode } from '@docusaurus/theme-common'; -import { ensureMarkdownItCompatibility } from './markdownItCompatibility'; +import { + NEDI_ENDPOINT, + loadNediAssets, + reloadNediAssets, + nediAssetsFailed, + nediDependenciesReady, +} from './assets'; -const NEDI_ENDPOINT = 'https://nedi.netdata.cloud'; const PERSISTENT_ID = 'nedi-persistent'; const SCROLL_KEY = 'nedi-scroll-y'; +const READY_POLL_INTERVAL = 150; +const READY_TIMEOUT = 15000; // Match Docusaurus theme colors for seamless integration const CSS_VARIABLES = { @@ -34,10 +41,7 @@ const CSS_VARIABLES = { }, }; -// Set source identifier for Nedi analytics -if (typeof window !== 'undefined') { - window.AI_AGENT_UI_SOURCE = 'learn'; -} +const STATUS_STYLE = { textAlign: 'center', padding: '40px' }; // Persistent container + instance, survives React unmounts function getOrCreateNedi(theme) { @@ -73,72 +77,100 @@ function getOrCreateNedi(theme) { export default function Nedi() { const mountRef = useRef(null); const { colorMode } = useColorMode(); + const colorModeRef = useRef(colorMode); + colorModeRef.current = colorMode; + + const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + + const retry = useCallback(() => { + reloadNediAssets(); + setFailed(false); + setAttempt((count) => count + 1); + }, []); + // Inject the embed and its dependencies, then wait for them to become usable. useEffect(() => { - if (!mountRef.current) return; - - let cleanups = []; - - const boot = () => { - if (!mountRef.current) return; - if (typeof window.AiAgentChatUI === 'undefined') return false; - if (!ensureMarkdownItCompatibility(window)) return false; - - const nediEl = getOrCreateNedi(colorMode); - - // Move persistent container into the mount point - mountRef.current.appendChild(nediEl); - nediEl.style.display = ''; - - // Ensure containers fill viewport so sticky footer stays at bottom even when empty. - // Uses document-relative position (rect.top + scrollY) so the value stays - // correct even when a resize fires while the user is scrolled down. - const setMinHeight = () => { - const top = mountRef.current.getBoundingClientRect().top + window.scrollY; - const vh = `calc(100vh - ${top}px)`; - mountRef.current.style.minHeight = vh; - nediEl.style.minHeight = vh; - const wrapper = nediEl.querySelector('.ai-agent-wrapper'); - if (wrapper) wrapper.style.minHeight = vh; - }; - requestAnimationFrame(setMinHeight); - // SPA back-navigation: layout may need extra time to settle - const settleTimer = setTimeout(setMinHeight, 150); - window.addEventListener('resize', setMinHeight); - - // Save scroll position continuously while on this page - const onScroll = () => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); - window.addEventListener('scroll', onScroll, { passive: true }); - - // Restore scroll position after DOM settles - const savedScroll = sessionStorage.getItem(SCROLL_KEY); - if (savedScroll) { - setTimeout(() => window.scrollTo(0, parseInt(savedScroll, 10)), 50); - } + if (ready || failed) return undefined; + + loadNediAssets(); + if (nediDependenciesReady()) { + setReady(true); + return undefined; + } - // Focus the chat input after DOM settles - requestAnimationFrame(() => { - const input = nediEl.querySelector('.ai-agent-input'); - if (input) input.focus(); - }); - - cleanups = [ - () => clearTimeout(settleTimer), - () => window.removeEventListener('scroll', onScroll), - () => window.removeEventListener('resize', setMinHeight), - () => { nediEl.style.display = 'none'; document.body.appendChild(nediEl); }, - ]; - return true; + const poll = setInterval(() => { + if (nediDependenciesReady()) setReady(true); + else if (nediAssetsFailed()) setFailed(true); + }, READY_POLL_INTERVAL); + + const timeout = setTimeout(() => setFailed(true), READY_TIMEOUT); + + return () => { + clearInterval(poll); + clearTimeout(timeout); }; + }, [ready, failed, attempt]); - // Try immediately; poll if async script hasn't loaded yet - if (!boot()) { - const poll = setInterval(() => { if (boot()) clearInterval(poll); }, 150); - cleanups.push(() => clearInterval(poll)); + useEffect(() => { + if (!ready || !mountRef.current) return undefined; + + const mount = mountRef.current; + let nediEl; + try { + nediEl = getOrCreateNedi(colorModeRef.current); + } catch { + setReady(false); + setFailed(true); + return undefined; } - return () => cleanups.forEach(fn => fn()); - }, []); + // Move persistent container into the mount point + mount.appendChild(nediEl); + nediEl.style.display = ''; + + // Ensure containers fill viewport so sticky footer stays at bottom even when empty. + // Uses document-relative position (rect.top + scrollY) so the value stays + // correct even when a resize fires while the user is scrolled down. + const setMinHeight = () => { + const top = mount.getBoundingClientRect().top + window.scrollY; + const vh = `calc(100vh - ${top}px)`; + mount.style.minHeight = vh; + nediEl.style.minHeight = vh; + const wrapper = nediEl.querySelector('.ai-agent-wrapper'); + if (wrapper) wrapper.style.minHeight = vh; + }; + requestAnimationFrame(setMinHeight); + // SPA back-navigation: layout may need extra time to settle + const settleTimer = setTimeout(setMinHeight, 150); + window.addEventListener('resize', setMinHeight); + + // Save scroll position continuously while on this page + const onScroll = () => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); + window.addEventListener('scroll', onScroll, { passive: true }); + + // Restore scroll position after DOM settles + const savedScroll = sessionStorage.getItem(SCROLL_KEY); + const scrollTimer = savedScroll + ? setTimeout(() => window.scrollTo(0, parseInt(savedScroll, 10)), 50) + : undefined; + + // Focus the chat input after DOM settles + requestAnimationFrame(() => { + const input = nediEl.querySelector('.ai-agent-input'); + if (input) input.focus(); + }); + + return () => { + clearTimeout(settleTimer); + clearTimeout(scrollTimer); + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', setMinHeight); + nediEl.style.display = 'none'; + document.body.appendChild(nediEl); + }; + }, [ready]); // Sync Docusaurus theme changes to Nedi useEffect(() => { @@ -148,5 +180,23 @@ export default function Nedi() { } }, [colorMode]); - return
; + return ( + <> +
+ {!ready && ( +
+ {failed ? ( + <> +

Ask Nedi could not be loaded.

+ + + ) : ( +

Loading Ask Nedi...

+ )} +
+ )} + + ); } diff --git a/src/components/Nedi/index.test.js b/src/components/Nedi/index.test.js new file mode 100644 index 000000000..0a4843bce --- /dev/null +++ b/src/components/Nedi/index.test.js @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; + +import { __setMockColorMode } from '@docusaurus/theme-common'; +import { + loadNediAssets, + reloadNediAssets, + nediAssetsFailed, + nediDependenciesReady, +} from './assets'; +import Nedi from './index'; + +vi.mock('./assets', () => ({ + NEDI_ENDPOINT: 'https://nedi.netdata.cloud', + loadNediAssets: vi.fn(), + reloadNediAssets: vi.fn(), + nediAssetsFailed: vi.fn(() => false), + nediDependenciesReady: vi.fn(() => false), +})); + +const PERSISTENT_ID = 'nedi-persistent'; +const READY_TIMEOUT = 15000; + +let embedOptions; +let setTheme; + +function installEmbed() { + window.AiAgentChatUI = function AiAgentChatUI(container, options) { + embedOptions = options; + container.innerHTML = + '
'; + this.setTheme = setTheme; + container.__nediInstance = this; + }; +} + +// Advances past one readiness poll interval and flushes the resulting render. +const tick = (ms) => act(() => vi.advanceTimersByTime(ms)); + +describe('Nedi component', () => { + beforeEach(() => { + vi.useFakeTimers(); + embedOptions = undefined; + setTheme = vi.fn(); + nediDependenciesReady.mockReturnValue(false); + nediAssetsFailed.mockReturnValue(false); + sessionStorage.clear(); + __setMockColorMode('light'); + }); + + afterEach(() => { + // Unmount first: the component parks the embed on document.body on cleanup. + cleanup(); + document.getElementById(PERSISTENT_ID)?.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + delete window.AiAgentChatUI; + delete window.AiAgentChatConfig; + delete window.posthog; + }); + + it('requests the assets and reports progress while they load', () => { + render(); + + expect(loadNediAssets).toHaveBeenCalledTimes(1); + expect(screen.getByRole('status')).toHaveTextContent('Loading Ask Nedi...'); + }); + + it('mounts the embed as soon as the dependencies resolve', () => { + installEmbed(); + const { container } = render(); + + nediDependenciesReady.mockReturnValue(true); + tick(150); + + const mounted = container.querySelector(`#${PERSISTENT_ID}`); + expect(mounted).not.toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + expect(window.AiAgentChatConfig).toEqual({ endpoint: 'https://nedi.netdata.cloud' }); + expect(embedOptions).toMatchObject({ mode: 'div', agentId: 'support-public', theme: 'light' }); + }); + + it('mounts without polling when the dependencies are already present', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + + const { container } = render(); + + expect(container.querySelector(`#${PERSISTENT_ID}`)).not.toBeNull(); + }); + + it('sizes the mount and the embed to the remaining viewport', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + const { container } = render(); + + tick(150); + + const mount = container.firstChild; + const mounted = document.getElementById(PERSISTENT_ID); + expect(mount.style.minHeight).toBe('calc(100vh - 0px)'); + expect(mounted.style.minHeight).toBe('calc(100vh - 0px)'); + expect(mounted.querySelector('.ai-agent-wrapper').style.minHeight).toBe('calc(100vh - 0px)'); + expect(document.activeElement).toBe(mounted.querySelector('.ai-agent-input')); + }); + + it('tolerates an embed that has not rendered its chat surface yet', () => { + window.AiAgentChatUI = function AiAgentChatUI() {}; + nediDependenciesReady.mockReturnValue(true); + render(); + + tick(150); + + const mounted = document.getElementById(PERSISTENT_ID); + expect(mounted.style.minHeight).toBe('calc(100vh - 0px)'); + expect(mounted.querySelector('.ai-agent-input')).toBeNull(); + }); + + it('restores the scroll position recorded on the previous visit', () => { + installEmbed(); + sessionStorage.setItem('nedi-scroll-y', '120'); + nediDependenciesReady.mockReturnValue(true); + render(); + + tick(150); + + expect(window.scrollTo).toHaveBeenCalledWith(0, 120); + }); + + it('records the scroll position while the page is open', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + render(); + tick(150); + + window.scrollY = 42; + act(() => window.dispatchEvent(new Event('scroll'))); + + expect(sessionStorage.getItem('nedi-scroll-y')).toBe('42'); + }); + + it('parks the embed outside the page on unmount so it survives navigation', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + const { unmount } = render(); + tick(150); + + unmount(); + + const parked = document.getElementById(PERSISTENT_ID); + expect(parked.parentElement).toBe(document.body); + expect(parked.style.display).toBe('none'); + }); + + it('reuses the embed instance across remounts', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + const first = render(); + tick(150); + const instance = document.getElementById(PERSISTENT_ID).__nediInstance; + first.unmount(); + + const second = render(); + tick(150); + + expect(second.container.querySelector(`#${PERSISTENT_ID}`).__nediInstance).toBe(instance); + }); + + it('forwards a question to the analytics client when one is available', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + window.posthog = { capture: vi.fn() }; + render(); + tick(150); + + embedOptions.onEvent({ type: 'user-message', content: 'why is my disk full' }); + embedOptions.onEvent({ type: 'other', content: 'ignored' }); + + expect(window.posthog.capture).toHaveBeenCalledTimes(1); + expect(window.posthog.capture).toHaveBeenCalledWith('nedi_question', { + question: 'why is my disk full', + }); + }); + + it('drops a question when no analytics client is loaded', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + render(); + tick(150); + + expect(() => embedOptions.onEvent({ type: 'user-message', content: 'q' })).not.toThrow(); + }); + + it('follows the Docusaurus color mode', () => { + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + const { rerender } = render(); + tick(150); + + __setMockColorMode('dark'); + rerender(); + + expect(setTheme).toHaveBeenCalledWith('dark'); + }); + + it('offers a retry when an asset fails to load', () => { + render(); + + nediAssetsFailed.mockReturnValue(true); + tick(150); + + expect(screen.getByRole('status')).toHaveTextContent('Ask Nedi could not be loaded.'); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('offers a retry when the dependencies never become usable', () => { + render(); + + tick(READY_TIMEOUT); + + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('re-injects the assets and resumes waiting when retried', () => { + render(); + tick(READY_TIMEOUT); + + act(() => screen.getByRole('button', { name: 'Retry' }).click()); + + expect(reloadNediAssets).toHaveBeenCalledTimes(1); + expect(screen.getByRole('status')).toHaveTextContent('Loading Ask Nedi...'); + + installEmbed(); + nediDependenciesReady.mockReturnValue(true); + tick(150); + + expect(document.getElementById(PERSISTENT_ID)).not.toBeNull(); + }); + + it('offers a retry when the embed refuses to start', () => { + window.AiAgentChatUI = function AiAgentChatUI() { + throw new Error('embed unavailable'); + }; + nediDependenciesReady.mockReturnValue(true); + + render(); + + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); From 3033dcbb75121872270c69eacc451a832ba1970f Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 19 Aug 2026 22:10:10 +0300 Subject: [PATCH 2/3] Make the Ask Nedi retry path recoverable Two states could survive a failed load and make Retry a no-op or worse. `getOrCreateNedi` attached the container to the document before starting the embed, so a constructor that threw left a container carrying the persistent id and no instance. The next attempt matched that container on its early return, skipped construction, reported success and rendered an empty embed with no working theme sync. The container is now removed before the error propagates. `reloadNediAssets` removed every injected element unconditionally, then delegated to `loadNediAssets`, which returns early when the dependencies are already usable. When the scripts had loaded but the embed itself failed to start, Retry therefore removed the stylesheet and injected nothing, leaving an unstyled embed. Reload now keeps a usable set and only clears the failure flag; removing a script element does not undo its side effects, so re-requesting one that already executed has no purpose. Both paths are covered by tests. --- src/components/Nedi/assets.js | 7 +++++- src/components/Nedi/assets.test.js | 16 +++++++++++++ src/components/Nedi/index.js | 38 +++++++++++++++++------------- src/components/Nedi/index.test.js | 24 +++++++++++++++++++ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/components/Nedi/assets.js b/src/components/Nedi/assets.js index 0d5194777..8a181586b 100644 --- a/src/components/Nedi/assets.js +++ b/src/components/Nedi/assets.js @@ -95,8 +95,13 @@ export function loadNediAssets() { }); } -// Discards a failed injection so the next load starts from a clean head. +// Discards a failed injection so the next load starts from a clean head. An already +// usable set is kept: removing a