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
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fetchApi from "./fetchApi";
const originalFetch = globalThis.fetch;
let calls: { url: string; init?: RequestInit }[] = [];
beforeEach(() => {
calls = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return new Response(JSON.stringify({ success: true }));
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("fetchApi", () => {
test("JSON-encodes object bodies without mutating the options", async () => {
const options = { method: "POST" as const, body: { a: 1 } };
await fetchApi("/api", options);
expect(calls[0]?.init?.body).toBe(JSON.stringify({ a: 1 }));
expect(options.body).toEqual({ a: 1 });
});
test("caller headers override the default Content-Type", async () => {
await fetchApi("/api", {
method: "POST",
body: "raw",
headers: { "content-type": "text/plain" } as any,
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(headers["content-type"]).toBe("text/plain");
expect(headers["Content-Type"]).toBeUndefined();
});
test("sends FormData as-is and lets the browser set Content-Type", async () => {
const formData = new FormData();
formData.append("file", "content");
await fetchApi("/api", { method: "POST", body: formData });
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(calls[0]?.init?.body).toBe(formData);
expect(headers["Content-Type"]).toBeUndefined();
});
test("uppercases the method and applies fetchOptions", async () => {
await fetchApi("/api", {
method: "patch",
fetchOptions: { credentials: "include" },
});
expect(calls[0]?.init?.method).toBe("PATCH");
expect(calls[0]?.init?.credentials).toBe("include");
});
test("adds the CSRF header and serializes the query", async () => {
await fetchApi("/api", {
method: "GET",
csrfKey: "x-csrf",
csrfValue: "token",
query: { page: 2 },
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(calls[0]?.url).toBe("/api?page=2");
expect(headers["x-csrf"]).toBe("token");
});
test("returns an error object when the request fails", async () => {
globalThis.fetch = (async () => {
throw new Error("Network down");
}) as unknown as typeof fetch;
const res = await fetchApi("/api", { method: "GET" });
expect(res).toEqual({ success: false, msg: "Network down" });
});
});
+147 -123
View File
@@ -1,123 +1,147 @@
import _ from "lodash";
import twuiSerializeQuery from "../serialize-query";
export const FetchAPIMethods = [
"POST",
"GET",
"DELETE",
"PUT",
"PATCH",
"post",
"get",
"delete",
"put",
"patch",
] as const;
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
method: (typeof FetchAPIMethods)[number];
body?: T | string;
headers?: FetchHeader;
query?: T;
csrfValue?: string;
csrfKey?: string;
fetchOptions?: RequestInit;
};
type FetchHeader = HeadersInit & {
[key: string]: string | null;
};
export type FetchApiReturn = {
success: boolean;
payload: any;
msg?: string;
[key: string]: any;
};
/**
* # Fetch API
*/
export default async function fetchApi<
T extends { [k: string]: any } = { [k: string]: any },
R extends any = any,
>(url: string, options?: FetchApiOptions<T>): Promise<R> {
let data;
let finalHeaders = {
"Content-Type": "application/json",
} as FetchHeader;
if (options?.csrfKey && options.csrfValue) {
finalHeaders[options.csrfKey] = options.csrfValue;
}
let finalURL = url;
if (options?.query) {
finalURL += twuiSerializeQuery(options.query);
}
if (typeof options === "string") {
try {
let fetchData;
switch (options) {
case "post":
fetchData = await fetch(finalURL, {
method: options,
headers: finalHeaders,
} as RequestInit);
data = await fetchData.json();
break;
default:
fetchData = await fetch(finalURL);
data = await fetchData.json();
break;
}
} catch (error: any) {
console.log("FetchAPI error #1:", error.message);
data = { success: false, msg: error.message };
}
} else if (typeof options === "object") {
try {
let fetchData;
if (options.body && typeof options.body === "object") {
let oldOptionsBody = _.cloneDeep(options.body);
options.body = JSON.stringify(oldOptionsBody);
}
if (options.headers) {
options.headers = _.merge(options.headers, finalHeaders);
const finalOptions: any = { ...options };
fetchData = await fetch(finalURL, finalOptions);
} else {
const finalOptions = {
...options,
headers: finalHeaders,
} as RequestInit;
fetchData = await fetch(finalURL, finalOptions);
}
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #2:", error.message);
data = { success: false, msg: error.message };
}
} else {
try {
let fetchData = await fetch(finalURL);
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #3:", error.message);
data = { success: false, msg: error.message };
}
}
return data;
}
import twuiSerializeQuery from "../serialize-query";
export const FetchAPIMethods = [
"POST",
"GET",
"DELETE",
"PUT",
"PATCH",
"post",
"get",
"delete",
"put",
"patch",
] as const;
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
method: (typeof FetchAPIMethods)[number];
body?: T | string | BodyInit;
headers?: FetchHeader;
query?: T;
csrfValue?: string;
csrfKey?: string;
fetchOptions?: RequestInit;
};
type FetchHeader = HeadersInit & {
[key: string]: string | null;
};
export type FetchApiReturn = {
success: boolean;
payload: any;
msg?: string;
[key: string]: any;
};
function isNativeBody(body: unknown): body is BodyInit {
return (
(typeof FormData !== "undefined" && body instanceof FormData) ||
(typeof Blob !== "undefined" && body instanceof Blob) ||
(typeof URLSearchParams !== "undefined" &&
body instanceof URLSearchParams) ||
body instanceof ArrayBuffer ||
ArrayBuffer.isView(body)
);
}
/**
* # Fetch API
*/
export default async function fetchApi<
T extends { [k: string]: any } = { [k: string]: any },
R extends any = any,
>(url: string, options?: FetchApiOptions<T>): Promise<R> {
let data;
let finalHeaders = {
"Content-Type": "application/json",
} as FetchHeader;
if (options?.csrfKey && options.csrfValue) {
finalHeaders[options.csrfKey] = options.csrfValue;
}
if (options?.headers) {
for (const [key, value] of Object.entries(options.headers)) {
if (value === null || value === undefined) continue;
// Header names are case-insensitive; caller values win
for (const existingKey of Object.keys(finalHeaders)) {
if (existingKey.toLowerCase() === key.toLowerCase()) {
delete finalHeaders[existingKey];
}
}
finalHeaders[key] = value;
}
}
let finalURL = url;
if (options?.query) {
finalURL += twuiSerializeQuery(options.query);
}
if (typeof options === "string") {
try {
let fetchData;
switch (options) {
case "post":
fetchData = await fetch(finalURL, {
method: options,
headers: finalHeaders,
} as RequestInit);
data = await fetchData.json();
break;
default:
fetchData = await fetch(finalURL);
data = await fetchData.json();
break;
}
} catch (error: any) {
console.log("FetchAPI error #1:", error.message);
data = { success: false, msg: error.message };
}
} else if (typeof options === "object") {
try {
let body: BodyInit | undefined;
if (isNativeBody(options.body)) {
body = options.body;
// Let the browser set the multipart boundary etc.
if (finalHeaders["Content-Type"] === "application/json") {
delete finalHeaders["Content-Type"];
}
} else if (options.body && typeof options.body === "object") {
body = JSON.stringify(options.body);
} else {
body = options.body as BodyInit | undefined;
}
const finalOptions: RequestInit = {
...options.fetchOptions,
method: options.method?.toUpperCase(),
headers: finalHeaders as HeadersInit,
body,
};
const fetchData = await fetch(finalURL, finalOptions);
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #2:", error.message);
data = { success: false, msg: error.message };
}
} else {
try {
let fetchData = await fetch(finalURL);
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #3:", error.message);
data = { success: false, msg: error.message };
}
}
return data;
}