preact-missing-hooks 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.husky/pre-push +1 -0
  3. package/.prettierignore +3 -0
  4. package/.prettierrc +6 -0
  5. package/Readme.md +333 -137
  6. package/dist/entry.cjs +21 -0
  7. package/dist/entry.js +2 -0
  8. package/dist/entry.js.map +1 -0
  9. package/dist/entry.modern.mjs +2 -0
  10. package/dist/entry.modern.mjs.map +1 -0
  11. package/dist/entry.module.js +2 -0
  12. package/dist/entry.module.js.map +1 -0
  13. package/dist/entry.umd.js +2 -0
  14. package/dist/entry.umd.js.map +1 -0
  15. package/dist/index.d.ts +14 -13
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.modern.mjs +2 -0
  19. package/dist/index.modern.mjs.map +1 -0
  20. package/dist/index.module.js +1 -1
  21. package/dist/index.module.js.map +1 -1
  22. package/dist/index.umd.js +1 -1
  23. package/dist/index.umd.js.map +1 -1
  24. package/dist/indexedDB/dbController.d.ts +2 -2
  25. package/dist/indexedDB/index.d.ts +6 -6
  26. package/dist/indexedDB/openDB.d.ts +1 -1
  27. package/dist/indexedDB/tableController.d.ts +1 -1
  28. package/dist/indexedDB/types.d.ts +1 -2
  29. package/dist/react.js +1 -0
  30. package/dist/react.modern.mjs +1 -0
  31. package/dist/react.module.js +1 -0
  32. package/dist/react.umd.js +1 -0
  33. package/dist/useEventBus.d.ts +1 -1
  34. package/dist/useIndexedDB.d.ts +3 -3
  35. package/dist/useLLMMetadata.d.ts +71 -0
  36. package/dist/useMutationObserver.d.ts +1 -1
  37. package/dist/useNetworkState.d.ts +3 -3
  38. package/dist/usePreferredTheme.d.ts +1 -1
  39. package/dist/useRageClick.d.ts +1 -1
  40. package/dist/useThreadedWorker.d.ts +1 -1
  41. package/dist/useTransition.d.ts +4 -1
  42. package/dist/useWorkerNotifications.d.ts +1 -1
  43. package/dist/useWrappedChildren.d.ts +3 -3
  44. package/docs/README.md +111 -0
  45. package/docs/index.html +58 -20
  46. package/docs/main.js +49 -0
  47. package/eslint.config.mjs +10 -0
  48. package/package.json +60 -6
  49. package/scripts/generate-entry.cjs +34 -0
  50. package/src/index.ts +14 -13
  51. package/src/indexedDB/dbController.ts +101 -92
  52. package/src/indexedDB/index.ts +16 -11
  53. package/src/indexedDB/openDB.ts +49 -49
  54. package/src/indexedDB/requestToPromise.ts +17 -16
  55. package/src/indexedDB/tableController.ts +331 -257
  56. package/src/indexedDB/types.ts +35 -35
  57. package/src/useClipboard.ts +99 -97
  58. package/src/useEventBus.ts +39 -36
  59. package/src/useIndexedDB.ts +111 -111
  60. package/src/useLLMMetadata.ts +418 -0
  61. package/src/useMutationObserver.ts +26 -26
  62. package/src/useNetworkState.ts +124 -122
  63. package/src/usePreferredTheme.ts +68 -68
  64. package/src/useRageClick.ts +103 -103
  65. package/src/useThreadedWorker.ts +165 -165
  66. package/src/useTransition.ts +22 -19
  67. package/src/useWasmCompute.ts +209 -204
  68. package/src/useWebRTCIP.ts +181 -176
  69. package/src/useWorkerNotifications.ts +28 -20
  70. package/src/useWrappedChildren.ts +72 -58
  71. package/tests/preact-as-react.ts +5 -0
  72. package/tests/react-adapter.tsx +12 -0
  73. package/tests/setup-react.ts +4 -0
  74. package/tests/useClipboard.test.tsx +4 -2
  75. package/tests/useLLMMetadata.test.tsx +149 -0
  76. package/tests/useThreadedWorker.test.tsx +3 -1
  77. package/tests/useWasmCompute.test.tsx +1 -1
  78. package/tests/useWebRTCIP.test.tsx +3 -1
  79. package/vite.config.ts +11 -4
  80. package/vitest.config.preact.ts +21 -0
  81. package/vitest.config.react.ts +36 -0
  82. package/vitest.workspace.ts +6 -0
@@ -1,176 +1,181 @@
1
- /**
2
- * useWebRTCIP – detect local/public IPs via WebRTC ICE candidates and STUN.
3
- * Not highly reliable; use as first-priority hint and fall back to a public IP API (e.g. ipapi.co) if needed.
4
- * @module useWebRTCIP
5
- */
6
-
7
- import { useEffect, useState, useRef } from 'preact/hooks';
8
-
9
- /** IPv4 regex for ICE candidate strings (captures dotted-decimal). */
10
- const IPV4_REGEX = /\b(?:25[0-5]|2[0-4]\d|1?\d{1,2})(?:\.(?:25[0-5]|2[0-4]\d|1?\d{1,2})){3}\b/g;
11
-
12
- const DEFAULT_STUN_SERVERS: string[] = ['stun:stun.l.google.com:19302'];
13
- const DEFAULT_TIMEOUT_MS = 3000;
14
-
15
- export interface UseWebRTCIPOptions {
16
- /** STUN server URLs (default: Google STUN). */
17
- stunServers?: string[];
18
- /** Stop gathering after this many ms (default: 3000). */
19
- timeout?: number;
20
- /** Called once per newly detected IP (no duplicates). */
21
- onDetect?: (ip: string) => void;
22
- }
23
-
24
- export interface UseWebRTCIPReturn {
25
- /** Unique IPv4 addresses found from ICE candidates. */
26
- ips: string[];
27
- /** True while ICE gathering is in progress. */
28
- loading: boolean;
29
- /** Error message if WebRTC is unavailable or detection fails. */
30
- error: string | null;
31
- }
32
-
33
- function isSSR(): boolean {
34
- return typeof window === 'undefined';
35
- }
36
-
37
- function isWebRTCAvailable(): boolean {
38
- return typeof RTCPeerConnection !== 'undefined';
39
- }
40
-
41
- /**
42
- * Extracts IPv4 addresses from an ICE candidate string.
43
- * Filters out common non-public/local patterns (e.g. 0.0.0.0) if desired; currently returns all matches.
44
- */
45
- function extractIPv4FromCandidate(candidate: string): string[] {
46
- const matches = candidate.match(IPV4_REGEX);
47
- return matches ? [...matches] : [];
48
- }
49
-
50
- /**
51
- * Attempts to detect client IP addresses using WebRTC ICE candidates and a STUN server.
52
- * Works frontend-only (no backend). Not guaranteed to return a public IP; use as a hint and
53
- * fall back to a public IP API (e.g. ipapi.co, ip-api.com) if you need reliability.
54
- *
55
- * @param options - Optional: stunServers, timeout (ms), onDetect(ip) callback.
56
- * @returns { ips, loading, error } – unique IPv4s, loading flag, and error message.
57
- *
58
- * @example
59
- * const { ips, loading, error } = useWebRTCIP({
60
- * timeout: 5000,
61
- * onDetect: (ip) => console.log('Detected:', ip),
62
- * })
63
- * // If ips is empty and error is set, fall back to: fetch('https://api.ipify.org?format=json')
64
- */
65
- export function useWebRTCIP(options: UseWebRTCIPOptions = {}): UseWebRTCIPReturn {
66
- const {
67
- stunServers = DEFAULT_STUN_SERVERS,
68
- timeout: timeoutMs = DEFAULT_TIMEOUT_MS,
69
- onDetect,
70
- } = options;
71
-
72
- const [ips, setIps] = useState<string[]>([]);
73
- const [loading, setLoading] = useState(true);
74
- const [error, setError] = useState<string | null>(null);
75
-
76
- const pcRef = useRef<RTCPeerConnection | null>(null);
77
- const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
- const reportedRef = useRef<Set<string>>(new Set());
79
- const onDetectRef = useRef(onDetect);
80
- onDetectRef.current = onDetect;
81
-
82
- useEffect(() => {
83
- if (isSSR()) {
84
- setLoading(false);
85
- setError('WebRTC IP detection is not available during SSR');
86
- return;
87
- }
88
-
89
- if (!isWebRTCAvailable()) {
90
- setLoading(false);
91
- setError('RTCPeerConnection is not available');
92
- return;
93
- }
94
-
95
- const reported = new Set<string>();
96
- reportedRef.current = reported;
97
-
98
- const finish = () => {
99
- if (timeoutRef.current) {
100
- clearTimeout(timeoutRef.current);
101
- timeoutRef.current = null;
102
- }
103
- if (pcRef.current) {
104
- pcRef.current.close();
105
- pcRef.current = null;
106
- }
107
- setLoading(false);
108
- };
109
-
110
- const addIP = (ip: string) => {
111
- if (reported.has(ip)) return;
112
- reported.add(ip);
113
- setIps((prev) => {
114
- const next = [...prev, ip];
115
- return next;
116
- });
117
- onDetectRef.current?.(ip);
118
- };
119
-
120
- try {
121
- const pc = new RTCPeerConnection({
122
- iceServers: [{ urls: stunServers }],
123
- });
124
- pcRef.current = pc;
125
-
126
- pc.onicecandidate = (event) => {
127
- const c = event.candidate;
128
- if (!c || !c.candidate) return;
129
- const found = extractIPv4FromCandidate(c.candidate);
130
- found.forEach(addIP);
131
- };
132
-
133
- pc.createDataChannel('');
134
-
135
- pc.createOffer()
136
- .then((offer) => pc.setLocalDescription(offer))
137
- .catch((err) => {
138
- setError(err instanceof Error ? err.message : 'Failed to create offer');
139
- finish();
140
- });
141
-
142
- timeoutRef.current = setTimeout(() => finish(), timeoutMs);
143
- } catch (err) {
144
- setError(err instanceof Error ? err.message : 'WebRTC setup failed');
145
- finish();
146
- }
147
-
148
- return () => {
149
- finish();
150
- };
151
- }, [stunServers.join(','), timeoutMs]);
152
-
153
- return { ips, loading, error };
154
- }
155
-
156
- /*
157
- * Example usage (Preact component):
158
- *
159
- * function MyIPDisplay() {
160
- * const { ips, loading, error } = useWebRTCIP({
161
- * timeout: 4000,
162
- * onDetect: (ip) => { / * optional: e.g. send to analytics * / },
163
- * })
164
- *
165
- * if (loading) return <p>Detecting IP…</p>
166
- * if (error) return <p>WebRTC failed: {error}. Try fallback API.</p>
167
- * return <p>IPs (WebRTC): {ips.join(', ') || 'None'}</p>
168
- * }
169
- *
170
- * Fallback to public IP API when WebRTC fails or returns empty:
171
- * const [apiIP, setApiIP] = useState<string | null>(null)
172
- * useEffect(() => {
173
- * if (!loading && ips.length === 0 && error)
174
- * fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => setApiIP(d.ip))
175
- * }, [loading, ips.length, error])
176
- */
1
+ /**
2
+ * useWebRTCIP – detect local/public IPs via WebRTC ICE candidates and STUN.
3
+ * Not highly reliable; use as first-priority hint and fall back to a public IP API (e.g. ipapi.co) if needed.
4
+ * @module useWebRTCIP
5
+ */
6
+
7
+ import { useEffect, useState, useRef } from "preact/hooks";
8
+
9
+ /** IPv4 regex for ICE candidate strings (captures dotted-decimal). */
10
+ const IPV4_REGEX =
11
+ /\b(?:25[0-5]|2[0-4]\d|1?\d{1,2})(?:\.(?:25[0-5]|2[0-4]\d|1?\d{1,2})){3}\b/g;
12
+
13
+ const DEFAULT_STUN_SERVERS: string[] = ["stun:stun.l.google.com:19302"];
14
+ const DEFAULT_TIMEOUT_MS = 3000;
15
+
16
+ export interface UseWebRTCIPOptions {
17
+ /** STUN server URLs (default: Google STUN). */
18
+ stunServers?: string[];
19
+ /** Stop gathering after this many ms (default: 3000). */
20
+ timeout?: number;
21
+ /** Called once per newly detected IP (no duplicates). */
22
+ onDetect?: (ip: string) => void;
23
+ }
24
+
25
+ export interface UseWebRTCIPReturn {
26
+ /** Unique IPv4 addresses found from ICE candidates. */
27
+ ips: string[];
28
+ /** True while ICE gathering is in progress. */
29
+ loading: boolean;
30
+ /** Error message if WebRTC is unavailable or detection fails. */
31
+ error: string | null;
32
+ }
33
+
34
+ function isSSR(): boolean {
35
+ return typeof window === "undefined";
36
+ }
37
+
38
+ function isWebRTCAvailable(): boolean {
39
+ return typeof RTCPeerConnection !== "undefined";
40
+ }
41
+
42
+ /**
43
+ * Extracts IPv4 addresses from an ICE candidate string.
44
+ * Filters out common non-public/local patterns (e.g. 0.0.0.0) if desired; currently returns all matches.
45
+ */
46
+ function extractIPv4FromCandidate(candidate: string): string[] {
47
+ const matches = candidate.match(IPV4_REGEX);
48
+ return matches ? [...matches] : [];
49
+ }
50
+
51
+ /**
52
+ * Attempts to detect client IP addresses using WebRTC ICE candidates and a STUN server.
53
+ * Works frontend-only (no backend). Not guaranteed to return a public IP; use as a hint and
54
+ * fall back to a public IP API (e.g. ipapi.co, ip-api.com) if you need reliability.
55
+ *
56
+ * @param options - Optional: stunServers, timeout (ms), onDetect(ip) callback.
57
+ * @returns { ips, loading, error } – unique IPv4s, loading flag, and error message.
58
+ *
59
+ * @example
60
+ * const { ips, loading, error } = useWebRTCIP({
61
+ * timeout: 5000,
62
+ * onDetect: (ip) => console.log('Detected:', ip),
63
+ * })
64
+ * // If ips is empty and error is set, fall back to: fetch('https://api.ipify.org?format=json')
65
+ */
66
+ export function useWebRTCIP(
67
+ options: UseWebRTCIPOptions = {}
68
+ ): UseWebRTCIPReturn {
69
+ const {
70
+ stunServers = DEFAULT_STUN_SERVERS,
71
+ timeout: timeoutMs = DEFAULT_TIMEOUT_MS,
72
+ onDetect,
73
+ } = options;
74
+
75
+ const [ips, setIps] = useState<string[]>([]);
76
+ const [loading, setLoading] = useState(true);
77
+ const [error, setError] = useState<string | null>(null);
78
+
79
+ const pcRef = useRef<RTCPeerConnection | null>(null);
80
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
81
+ const reportedRef = useRef<Set<string>>(new Set());
82
+ const onDetectRef = useRef(onDetect);
83
+ onDetectRef.current = onDetect;
84
+
85
+ useEffect(() => {
86
+ if (isSSR()) {
87
+ setLoading(false);
88
+ setError("WebRTC IP detection is not available during SSR");
89
+ return;
90
+ }
91
+
92
+ if (!isWebRTCAvailable()) {
93
+ setLoading(false);
94
+ setError("RTCPeerConnection is not available");
95
+ return;
96
+ }
97
+
98
+ const reported = new Set<string>();
99
+ reportedRef.current = reported;
100
+
101
+ const finish = () => {
102
+ if (timeoutRef.current) {
103
+ clearTimeout(timeoutRef.current);
104
+ timeoutRef.current = null;
105
+ }
106
+ if (pcRef.current) {
107
+ pcRef.current.close();
108
+ pcRef.current = null;
109
+ }
110
+ setLoading(false);
111
+ };
112
+
113
+ const addIP = (ip: string) => {
114
+ if (reported.has(ip)) return;
115
+ reported.add(ip);
116
+ setIps((prev) => {
117
+ const next = [...prev, ip];
118
+ return next;
119
+ });
120
+ onDetectRef.current?.(ip);
121
+ };
122
+
123
+ try {
124
+ const pc = new RTCPeerConnection({
125
+ iceServers: [{ urls: stunServers }],
126
+ });
127
+ pcRef.current = pc;
128
+
129
+ pc.onicecandidate = (event) => {
130
+ const c = event.candidate;
131
+ if (!c || !c.candidate) return;
132
+ const found = extractIPv4FromCandidate(c.candidate);
133
+ found.forEach(addIP);
134
+ };
135
+
136
+ pc.createDataChannel("");
137
+
138
+ pc.createOffer()
139
+ .then((offer) => pc.setLocalDescription(offer))
140
+ .catch((err) => {
141
+ setError(
142
+ err instanceof Error ? err.message : "Failed to create offer"
143
+ );
144
+ finish();
145
+ });
146
+
147
+ timeoutRef.current = setTimeout(() => finish(), timeoutMs);
148
+ } catch (err) {
149
+ setError(err instanceof Error ? err.message : "WebRTC setup failed");
150
+ finish();
151
+ }
152
+
153
+ return () => {
154
+ finish();
155
+ };
156
+ }, [stunServers.join(","), timeoutMs]);
157
+
158
+ return { ips, loading, error };
159
+ }
160
+
161
+ /*
162
+ * Example usage (Preact component):
163
+ *
164
+ * function MyIPDisplay() {
165
+ * const { ips, loading, error } = useWebRTCIP({
166
+ * timeout: 4000,
167
+ * onDetect: (ip) => { / * optional: e.g. send to analytics * / },
168
+ * })
169
+ *
170
+ * if (loading) return <p>Detecting IP…</p>
171
+ * if (error) return <p>WebRTC failed: {error}. Try fallback API.</p>
172
+ * return <p>IPs (WebRTC): {ips.join(', ') || 'None'}</p>
173
+ * }
174
+ *
175
+ * Fallback to public IP API when WebRTC fails or returns empty:
176
+ * const [apiIP, setApiIP] = useState<string | null>(null)
177
+ * useEffect(() => {
178
+ * if (!loading && ips.length === 0 && error)
179
+ * fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => setApiIP(d.ip))
180
+ * }, [loading, ips.length, error])
181
+ */
@@ -3,10 +3,14 @@
3
3
  * @module useWorkerNotifications
4
4
  */
5
5
 
6
- import { useState, useRef, useEffect, useMemo } from 'preact/hooks';
6
+ import { useState, useRef, useEffect, useMemo } from "preact/hooks";
7
7
 
8
8
  /** Supported worker event types for tracking. Worker should postMessage with these shapes. */
9
- export type WorkerEventType = 'task_start' | 'task_end' | 'task_fail' | 'queue_size';
9
+ export type WorkerEventType =
10
+ | "task_start"
11
+ | "task_end"
12
+ | "task_fail"
13
+ | "queue_size";
10
14
 
11
15
  export interface WorkerNotificationEvent {
12
16
  type: WorkerEventType;
@@ -53,21 +57,21 @@ export interface UseWorkerNotificationsReturn {
53
57
  }
54
58
 
55
59
  function parseMessage(data: unknown): WorkerNotificationEvent | null {
56
- if (data == null || typeof data !== 'object') return null;
60
+ if (data == null || typeof data !== "object") return null;
57
61
  const d = data as Record<string, unknown>;
58
62
  const type = d.type as string;
59
63
  if (
60
- type !== 'task_start' &&
61
- type !== 'task_end' &&
62
- type !== 'task_fail' &&
63
- type !== 'queue_size'
64
+ type !== "task_start" &&
65
+ type !== "task_end" &&
66
+ type !== "task_fail" &&
67
+ type !== "queue_size"
64
68
  ) {
65
69
  return null;
66
70
  }
67
- const taskId = typeof d.taskId === 'string' ? d.taskId : undefined;
68
- const duration = typeof d.duration === 'number' ? d.duration : undefined;
69
- const error = typeof d.error === 'string' ? d.error : undefined;
70
- const size = typeof d.size === 'number' ? d.size : undefined;
71
+ const taskId = typeof d.taskId === "string" ? d.taskId : undefined;
72
+ const duration = typeof d.duration === "number" ? d.duration : undefined;
73
+ const error = typeof d.error === "string" ? d.error : undefined;
74
+ const size = typeof d.size === "number" ? d.size : undefined;
71
75
  return {
72
76
  type: type as WorkerEventType,
73
77
  taskId,
@@ -96,7 +100,9 @@ export function useWorkerNotifications(
96
100
  const [runningTasks, setRunningTasks] = useState<string[]>([]);
97
101
  const [completedCount, setCompletedCount] = useState(0);
98
102
  const [failedCount, setFailedCount] = useState(0);
99
- const [eventHistory, setEventHistory] = useState<WorkerNotificationEvent[]>([]);
103
+ const [eventHistory, setEventHistory] = useState<WorkerNotificationEvent[]>(
104
+ []
105
+ );
100
106
  const [currentQueueSize, setCurrentQueueSize] = useState(0);
101
107
 
102
108
  const completedTimestampsRef = useRef<number[]>([]);
@@ -115,11 +121,11 @@ export function useWorkerNotifications(
115
121
  return next;
116
122
  });
117
123
 
118
- if (ev.type === 'task_start' && ev.taskId) {
124
+ if (ev.type === "task_start" && ev.taskId) {
119
125
  setRunningTasks((prev) =>
120
126
  prev.includes(ev.taskId!) ? prev : [...prev, ev.taskId!]
121
127
  );
122
- } else if (ev.type === 'task_end') {
128
+ } else if (ev.type === "task_end") {
123
129
  if (ev.taskId) {
124
130
  setRunningTasks((prev) => prev.filter((id) => id !== ev.taskId));
125
131
  }
@@ -129,22 +135,22 @@ export function useWorkerNotifications(
129
135
  ...completedTimestampsRef.current.filter((t) => t >= cutoff),
130
136
  ev.timestamp,
131
137
  ];
132
- if (typeof ev.duration === 'number') {
138
+ if (typeof ev.duration === "number") {
133
139
  durationSumRef.current += ev.duration;
134
140
  durationCountRef.current += 1;
135
141
  }
136
- } else if (ev.type === 'task_fail') {
142
+ } else if (ev.type === "task_fail") {
137
143
  if (ev.taskId) {
138
144
  setRunningTasks((prev) => prev.filter((id) => id !== ev.taskId));
139
145
  }
140
146
  setFailedCount((c) => c + 1);
141
- } else if (ev.type === 'queue_size' && typeof ev.size === 'number') {
147
+ } else if (ev.type === "queue_size" && typeof ev.size === "number") {
142
148
  setCurrentQueueSize(ev.size);
143
149
  }
144
150
  };
145
151
 
146
- worker.addEventListener('message', onMessage);
147
- return () => worker.removeEventListener('message', onMessage);
152
+ worker.addEventListener("message", onMessage);
153
+ return () => worker.removeEventListener("message", onMessage);
148
154
  }, [worker, maxHistory]);
149
155
 
150
156
  const averageDurationMs = useMemo(() => {
@@ -156,7 +162,9 @@ export function useWorkerNotifications(
156
162
  const throughputPerSecond = useMemo(() => {
157
163
  const now = Date.now();
158
164
  const cutoff = now - throughputWindowMs;
159
- const timestamps = completedTimestampsRef.current.filter((t) => t >= cutoff);
165
+ const timestamps = completedTimestampsRef.current.filter(
166
+ (t) => t >= cutoff
167
+ );
160
168
  return timestamps.length / (throughputWindowMs / 1000);
161
169
  }, [eventHistory, throughputWindowMs]);
162
170
 
@@ -1,58 +1,72 @@
1
- import { ComponentChildren, cloneElement, isValidElement } from 'preact'
2
- import { useMemo } from 'preact/hooks'
3
-
4
- export type InjectableProps = Record<string, any>
5
-
6
- /**
7
- * A Preact hook to wrap children components and inject additional props into them.
8
- * @param children - The children to wrap and enhance with props.
9
- * @param injectProps - The props to inject into each child component.
10
- * @param mergeStrategy - How to handle prop conflicts ('override' | 'preserve'). Defaults to 'preserve'.
11
- * @returns Enhanced children with injected props.
12
- */
13
- export function useWrappedChildren(
14
- children: ComponentChildren,
15
- injectProps: InjectableProps,
16
- mergeStrategy: 'override' | 'preserve' = 'preserve'
17
- ): ComponentChildren {
18
- return useMemo(() => {
19
- if (!children) return children
20
-
21
- const enhanceChild = (child: any): any => {
22
- if (!isValidElement(child)) return child
23
-
24
- const existingProps = child.props || {}
25
-
26
- let mergedProps: InjectableProps
27
-
28
- if (mergeStrategy === 'override') {
29
- // Injected props override existing ones
30
- mergedProps = { ...existingProps, ...injectProps }
31
- } else {
32
- // Existing props are preserved, injected props are added only if not present
33
- mergedProps = { ...injectProps, ...existingProps }
34
- }
35
-
36
- // Special handling for style prop to merge style objects properly
37
- const existingStyle = (existingProps as any)?.style
38
- const injectStyle = (injectProps as any)?.style
39
-
40
- if (existingStyle && injectStyle &&
41
- typeof existingStyle === 'object' && typeof injectStyle === 'object') {
42
- if (mergeStrategy === 'override') {
43
- (mergedProps as any).style = { ...existingStyle, ...injectStyle }
44
- } else {
45
- (mergedProps as any).style = { ...injectStyle, ...existingStyle }
46
- }
47
- }
48
-
49
- return cloneElement(child, mergedProps)
50
- }
51
-
52
- if (Array.isArray(children)) {
53
- return children.map(enhanceChild)
54
- }
55
-
56
- return enhanceChild(children)
57
- }, [children, injectProps, mergeStrategy])
58
- }
1
+ import { ComponentChildren, cloneElement, isValidElement, VNode } from "preact";
2
+ import { useMemo } from "preact/hooks";
3
+
4
+ export type InjectableProps = Record<string, unknown>;
5
+
6
+ interface PropsWithStyle {
7
+ style?: Record<string, string | number>;
8
+ }
9
+
10
+ /**
11
+ * A Preact hook to wrap children components and inject additional props into them.
12
+ * @param children - The children to wrap and enhance with props.
13
+ * @param injectProps - The props to inject into each child component.
14
+ * @param mergeStrategy - How to handle prop conflicts ('override' | 'preserve'). Defaults to 'preserve'.
15
+ * @returns Enhanced children with injected props.
16
+ */
17
+ export function useWrappedChildren(
18
+ children: ComponentChildren,
19
+ injectProps: InjectableProps,
20
+ mergeStrategy: "override" | "preserve" = "preserve"
21
+ ): ComponentChildren {
22
+ return useMemo(() => {
23
+ if (!children) return children;
24
+
25
+ const enhanceChild = (child: ComponentChildren): ComponentChildren => {
26
+ if (!isValidElement(child)) return child;
27
+
28
+ const existingProps = (child as VNode).props || {};
29
+
30
+ let mergedProps: InjectableProps;
31
+
32
+ if (mergeStrategy === "override") {
33
+ // Injected props override existing ones
34
+ mergedProps = { ...existingProps, ...injectProps };
35
+ } else {
36
+ // Existing props are preserved, injected props are added only if not present
37
+ mergedProps = { ...injectProps, ...existingProps };
38
+ }
39
+
40
+ // Special handling for style prop to merge style objects properly
41
+ const existingStyle = (existingProps as PropsWithStyle)?.style;
42
+ const injectStyle = (injectProps as PropsWithStyle)?.style;
43
+
44
+ if (
45
+ existingStyle &&
46
+ injectStyle &&
47
+ typeof existingStyle === "object" &&
48
+ typeof injectStyle === "object"
49
+ ) {
50
+ if (mergeStrategy === "override") {
51
+ (mergedProps as PropsWithStyle).style = {
52
+ ...existingStyle,
53
+ ...injectStyle,
54
+ };
55
+ } else {
56
+ (mergedProps as PropsWithStyle).style = {
57
+ ...injectStyle,
58
+ ...existingStyle,
59
+ };
60
+ }
61
+ }
62
+
63
+ return cloneElement(child, mergedProps);
64
+ };
65
+
66
+ if (Array.isArray(children)) {
67
+ return children.map(enhanceChild);
68
+ }
69
+
70
+ return enhanceChild(children);
71
+ }, [children, injectProps, mergeStrategy]);
72
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Re-exports Preact hooks as "react" for tests so useLLMMetadata (which imports from "react")
3
+ * uses the same renderer as the Preact test components.
4
+ */
5
+ export { useEffect, useRef } from "preact/hooks";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Adapter so the same test files can run with React: provides Preact-like exports.
3
+ * Used when running tests with the "react" Vitest project (alias preact → this file).
4
+ */
5
+ import * as React from "react";
6
+
7
+ export const h = React.createElement;
8
+ export const cloneElement = React.cloneElement;
9
+ export const isValidElement = React.isValidElement;
10
+ export type ComponentChildren = React.ReactNode;
11
+ export type RefObject<T> = React.RefObject<T>;
12
+ export type VNode = React.ReactElement;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Setup for React Vitest project. Ensures tests can detect they run under React.
3
+ */
4
+ (globalThis as unknown as { __VITEST_REACT__?: boolean }).__VITEST_REACT__ = true;