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:
@@ -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]);
|
||||
|
||||
@@ -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 || [])]);
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user