Fix component bugs across hooks, elements, form, layout, editors and utils

- Hooks: per-instance timers in useReady; cleanup for useIntersectionObserver;
  useWebSocket no longer reconnects after unmount/refresh, drains the queue
  and dispatches non-JSON messages
- Modal/Popover/Dropdown: onClose only on close, Escape to close, no stray
  DOM props, hooks before early return, scrollbar-aware positioning,
  top-left/top-right dropdown positions
- Elements: Toast/Search/StarRating per-instance timers, Pagination labels
  and empty state, Breadcrumbs titles, LinkList dividers/props, segment-wise
  active-link matching (new utils/link-matches-path), Table object cells
- Form: Select with a default option is changeable, Input clear notifies
  controlled parents and copy works on textareas, Checkbox random label,
  NumberInputButtons runaway interval, upload prop/spread fixes
- Layout: Button disabled while loading, Stack gap and Span truncate via
  inline styles, Img fallbackImageSrc, className/JSDoc fixes
- Editors/MDX/AI: TinyMCE id/cleanup/stale handler/duplicate scripts,
  AceEditor debounce, markdown heights and frontmatter regex, last
  assistant message, AIPromptBlock prompt ref
- Utils: fetchApi headers/FormData/fetchOptions/no mutation, base64 file
  names and read errors, slugify underscore trim, serializeQuery zero values
- Tests for fetchApi, link matching, slugify and serializeQuery
- AGENTS.md updated for the package layout

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 11da9b89e7
commit cdeeb8201e
56 changed files with 1128 additions and 722 deletions
+4 -4
View File
@@ -24,8 +24,6 @@ export type AceEditorComponentType = {
showLabel?: boolean;
};
let timeout: any;
/**
* # Powerful Ace Editor
* @note **NOTE** head scripts required
@@ -51,6 +49,7 @@ export default function AceEditor({
try {
const editorElementRef = React.useRef<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const changeTimeoutRef = React.useRef<any>(null);
const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false);
@@ -106,9 +105,9 @@ export default function AceEditor({
editor.getSession().on("change", function (e) {
if (onChange) {
clearTimeout(timeout);
window.clearTimeout(changeTimeoutRef.current);
setTimeout(() => {
changeTimeoutRef.current = setTimeout(() => {
try {
onChange(
editor.getValue(),
@@ -123,6 +122,7 @@ export default function AceEditor({
if (editorRef) editorRef.current = editor;
return function () {
window.clearTimeout(changeTimeoutRef.current);
editor.destroy();
};
}, [refresh, darkMode, ready, mode]);
+24 -20
View File
@@ -52,8 +52,12 @@ export default function TinyMCEEditor<KeyType extends string>({
const { tinyMCE } = useTinyMCE({ base_url });
const editorComponentRef = React.useRef<HTMLDivElement>(null);
const internalEditorRef = React.useRef<Editor | null>(null);
const editorRef: RefObject<Editor | null> =
passedEditorRef || React.useRef(null);
passedEditorRef || internalEditorRef;
const changeHandlerRef = React.useRef(changeHandler);
changeHandlerRef.current = changeHandler;
const valueTimeoutRef = React.useRef<any>(null);
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
@@ -79,21 +83,21 @@ export default function TinyMCEEditor<KeyType extends string>({
}, 200);
}, [tinyMCE]);
let valueTimeout: any;
const id = crypto.randomUUID();
// Stable across renders (and works outside secure contexts, unlike
// crypto.randomUUID) so cleanup can find the editor instance by id
const id = `twui-tinymce-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const updateContent = useCallback((editor: Editor) => {
if (changeHandler) {
window.clearTimeout(valueTimeout);
if (!changeHandlerRef.current) return;
valueTimeout = setTimeout(() => {
changeHandler(
editor.getContent(),
editorRef.current || undefined,
);
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}
window.clearTimeout(valueTimeoutRef.current);
valueTimeoutRef.current = setTimeout(() => {
changeHandlerRef.current?.(
editor.getContent(),
editorRef.current || undefined,
);
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}, []);
React.useEffect(() => {
@@ -105,7 +109,7 @@ export default function TinyMCEEditor<KeyType extends string>({
base_url ||
getTinyMCEDefaultBaseUrl();
tinyMCE.init({
const initPromise = tinyMCE.init({
height: FINAL_HEIGHT,
menubar: false,
plugins:
@@ -154,12 +158,12 @@ export default function TinyMCEEditor<KeyType extends string>({
});
return function () {
if (!ready) return;
const instance = editorComponentRef.current
? tinyMCE?.get(editorComponentRef.current?.id)
: undefined;
instance?.remove();
window.clearTimeout(valueTimeoutRef.current);
tinyMCE?.get(id)?.remove();
// Init is async: also remove editors that finish after cleanup
initPromise
?.then((editors) => editors.forEach((editor) => editor.remove()))
.catch(() => {});
};
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
+14 -3
View File
@@ -2,8 +2,6 @@ import React from "react";
import type { TinyMCE } from "./tinymce";
import getTinyMCEDefaultBaseUrl from "./default-base-url";
let interval: any;
type Params = {
base_url?: string;
};
@@ -29,7 +27,20 @@ export default function useTinyMCE(params?: Params) {
params?.base_url ||
getTinyMCEDefaultBaseUrl();
script.src = `${baseUrl}/tinymce.min.js`;
const src = `${baseUrl}/tinymce.min.js`;
// Several editors may mount at once; share a single script tag
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${src}"]`,
);
if (existingScript) {
const onLoad = () => setScriptLoaded(true);
existingScript.addEventListener("load", onLoad);
return () => existingScript.removeEventListener("load", onLoad);
}
script.src = src;
script.async = true;
script.onload = () => {