cogsbox-state 0.5.465 → 0.5.466
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.
- package/README.md +3 -3
- package/dist/CogsState.d.ts +1 -0
- package/dist/CogsState.d.ts.map +1 -1
- package/dist/CogsState.jsx +1011 -1270
- package/dist/CogsState.jsx.map +1 -1
- package/dist/Components.d.ts +39 -0
- package/dist/Components.d.ts.map +1 -0
- package/dist/Components.jsx +281 -0
- package/dist/Components.jsx.map +1 -0
- package/dist/index.js +11 -12
- package/dist/store.d.ts +2 -5
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +261 -219
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
- package/src/CogsState.tsx +151 -707
- package/src/Components.tsx +541 -0
- package/src/store.ts +178 -141
- package/dist/Functions.d.ts +0 -11
- package/dist/Functions.d.ts.map +0 -1
- package/dist/Functions.jsx +0 -29
- package/dist/Functions.jsx.map +0 -1
- package/src/Functions.tsx +0 -66
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { FormElementParams, type FormOptsType } from './CogsState';
|
|
2
|
+
import React, {
|
|
3
|
+
memo,
|
|
4
|
+
RefObject,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { formRefStore, getGlobalStore, ValidationError } from './store';
|
|
12
|
+
import { useInView } from 'react-intersection-observer';
|
|
13
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
14
|
+
import { isDeepEqual } from './utility';
|
|
15
|
+
const {
|
|
16
|
+
getInitialOptions,
|
|
17
|
+
|
|
18
|
+
getShadowMetadata,
|
|
19
|
+
setShadowMetadata,
|
|
20
|
+
getShadowValue,
|
|
21
|
+
|
|
22
|
+
registerComponent,
|
|
23
|
+
unregisterComponent,
|
|
24
|
+
|
|
25
|
+
notifyPathSubscribers,
|
|
26
|
+
subscribeToPath,
|
|
27
|
+
} = getGlobalStore.getState();
|
|
28
|
+
export type ValidationWrapperProps = {
|
|
29
|
+
formOpts?: FormOptsType;
|
|
30
|
+
path: string[];
|
|
31
|
+
stateKey: string;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function ValidationWrapper({
|
|
36
|
+
formOpts,
|
|
37
|
+
path,
|
|
38
|
+
stateKey,
|
|
39
|
+
children,
|
|
40
|
+
}: ValidationWrapperProps) {
|
|
41
|
+
const { getInitialOptions, getShadowMetadata, getShadowValue } =
|
|
42
|
+
getGlobalStore.getState();
|
|
43
|
+
const thisStateOpts = getInitialOptions(stateKey!);
|
|
44
|
+
|
|
45
|
+
const shadowMeta = getShadowMetadata(stateKey!, path);
|
|
46
|
+
const validationState = shadowMeta?.validation;
|
|
47
|
+
|
|
48
|
+
const status = validationState?.status || 'NOT_VALIDATED';
|
|
49
|
+
|
|
50
|
+
const errors = (validationState?.errors || []).map((err) => ({
|
|
51
|
+
...err,
|
|
52
|
+
path: path,
|
|
53
|
+
})) as ValidationError[];
|
|
54
|
+
const errorMessages = errors
|
|
55
|
+
.filter((err) => err.severity === 'error')
|
|
56
|
+
.map((err) => err.message);
|
|
57
|
+
const warningMessages = errors
|
|
58
|
+
.filter((err) => err.severity === 'warning')
|
|
59
|
+
.map((err) => err.message);
|
|
60
|
+
|
|
61
|
+
// Use first error, or first warning if no errors
|
|
62
|
+
const message = errorMessages[0] || warningMessages[0];
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{thisStateOpts?.formElements?.validation &&
|
|
67
|
+
!formOpts?.validation?.disable ? (
|
|
68
|
+
thisStateOpts.formElements!.validation!({
|
|
69
|
+
children: (
|
|
70
|
+
<React.Fragment key={path.toString()}>{children}</React.Fragment>
|
|
71
|
+
),
|
|
72
|
+
status, // Now passes the new ValidationStatus type
|
|
73
|
+
message: formOpts?.validation?.hideMessage
|
|
74
|
+
? ''
|
|
75
|
+
: formOpts?.validation?.message || message || '',
|
|
76
|
+
|
|
77
|
+
hasErrors: errorMessages.length > 0,
|
|
78
|
+
hasWarnings: warningMessages.length > 0,
|
|
79
|
+
allErrors: errors,
|
|
80
|
+
path: path,
|
|
81
|
+
getData: () => getShadowValue(stateKey!, path),
|
|
82
|
+
})
|
|
83
|
+
) : (
|
|
84
|
+
<React.Fragment key={path.toString()}>{children}</React.Fragment>
|
|
85
|
+
)}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
export const MemoizedCogsItemWrapper = memo(
|
|
90
|
+
ListItemWrapper,
|
|
91
|
+
(prevProps, nextProps) => {
|
|
92
|
+
// Re-render if any of these change:
|
|
93
|
+
return (
|
|
94
|
+
prevProps.itemPath.join('.') === nextProps.itemPath.join('.') &&
|
|
95
|
+
prevProps.stateKey === nextProps.stateKey &&
|
|
96
|
+
prevProps.itemComponentId === nextProps.itemComponentId &&
|
|
97
|
+
prevProps.localIndex === nextProps.localIndex
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
export function ListItemWrapper({
|
|
102
|
+
stateKey,
|
|
103
|
+
itemComponentId,
|
|
104
|
+
itemPath,
|
|
105
|
+
localIndex,
|
|
106
|
+
arraySetter,
|
|
107
|
+
rebuildStateShape,
|
|
108
|
+
renderFn,
|
|
109
|
+
}: {
|
|
110
|
+
stateKey: string;
|
|
111
|
+
itemComponentId: string;
|
|
112
|
+
itemPath: string[];
|
|
113
|
+
localIndex: number;
|
|
114
|
+
arraySetter: any;
|
|
115
|
+
|
|
116
|
+
rebuildStateShape: (options: {
|
|
117
|
+
currentState: any;
|
|
118
|
+
path: string[];
|
|
119
|
+
componentId: string;
|
|
120
|
+
meta?: any;
|
|
121
|
+
}) => any;
|
|
122
|
+
renderFn: (
|
|
123
|
+
setter: any,
|
|
124
|
+
index: number,
|
|
125
|
+
|
|
126
|
+
arraySetter: any
|
|
127
|
+
) => React.ReactNode;
|
|
128
|
+
}) {
|
|
129
|
+
const [, forceUpdate] = useState({});
|
|
130
|
+
const { ref: inViewRef, inView } = useInView();
|
|
131
|
+
const elementRef = useRef<HTMLDivElement | null>(null);
|
|
132
|
+
|
|
133
|
+
const imagesLoaded = useImageLoaded(elementRef);
|
|
134
|
+
const hasReportedInitialHeight = useRef(false);
|
|
135
|
+
const fullKey = [stateKey, ...itemPath].join('.');
|
|
136
|
+
useRegisterComponent(stateKey, itemComponentId, forceUpdate);
|
|
137
|
+
|
|
138
|
+
const setRefs = useCallback(
|
|
139
|
+
(element: HTMLDivElement | null) => {
|
|
140
|
+
elementRef.current = element;
|
|
141
|
+
inViewRef(element); // This is the ref from useInView
|
|
142
|
+
},
|
|
143
|
+
[inViewRef]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const unsubscribe = subscribeToPath(fullKey, (e) => {
|
|
148
|
+
forceUpdate({});
|
|
149
|
+
});
|
|
150
|
+
return () => unsubscribe();
|
|
151
|
+
}, [fullKey]);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!inView || !imagesLoaded || hasReportedInitialHeight.current) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const element = elementRef.current;
|
|
158
|
+
if (element && element.offsetHeight > 0) {
|
|
159
|
+
hasReportedInitialHeight.current = true;
|
|
160
|
+
const newHeight = element.offsetHeight;
|
|
161
|
+
|
|
162
|
+
setShadowMetadata(stateKey, itemPath, {
|
|
163
|
+
virtualizer: {
|
|
164
|
+
itemHeight: newHeight,
|
|
165
|
+
domRef: element,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const arrayPath = itemPath.slice(0, -1);
|
|
170
|
+
const arrayPathKey = [stateKey, ...arrayPath].join('.');
|
|
171
|
+
notifyPathSubscribers(arrayPathKey, {
|
|
172
|
+
type: 'ITEMHEIGHT',
|
|
173
|
+
itemKey: itemPath.join('.'),
|
|
174
|
+
|
|
175
|
+
ref: elementRef.current,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}, [inView, imagesLoaded, stateKey, itemPath]);
|
|
179
|
+
|
|
180
|
+
const itemValue = getShadowValue(stateKey, itemPath);
|
|
181
|
+
|
|
182
|
+
if (itemValue === undefined) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const itemSetter = rebuildStateShape({
|
|
187
|
+
currentState: itemValue,
|
|
188
|
+
path: itemPath,
|
|
189
|
+
componentId: itemComponentId,
|
|
190
|
+
});
|
|
191
|
+
const children = renderFn(itemSetter, localIndex, arraySetter);
|
|
192
|
+
|
|
193
|
+
return <div ref={setRefs}>{children}</div>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function FormElementWrapper({
|
|
197
|
+
stateKey,
|
|
198
|
+
path,
|
|
199
|
+
rebuildStateShape,
|
|
200
|
+
renderFn,
|
|
201
|
+
formOpts,
|
|
202
|
+
setState,
|
|
203
|
+
}: {
|
|
204
|
+
stateKey: string;
|
|
205
|
+
path: string[];
|
|
206
|
+
rebuildStateShape: (options: {
|
|
207
|
+
path: string[];
|
|
208
|
+
componentId: string;
|
|
209
|
+
meta?: any;
|
|
210
|
+
}) => any;
|
|
211
|
+
renderFn: (params: FormElementParams<any>) => React.ReactNode;
|
|
212
|
+
formOpts?: FormOptsType;
|
|
213
|
+
setState: any;
|
|
214
|
+
}) {
|
|
215
|
+
const [componentId] = useState(() => uuidv4());
|
|
216
|
+
const [, forceUpdate] = useState({});
|
|
217
|
+
|
|
218
|
+
const stateKeyPathKey = [stateKey, ...path].join('.');
|
|
219
|
+
useRegisterComponent(stateKey, componentId, forceUpdate);
|
|
220
|
+
const globalStateValue = getShadowValue(stateKey, path);
|
|
221
|
+
const [localValue, setLocalValue] = useState<any>(globalStateValue);
|
|
222
|
+
const isCurrentlyDebouncing = useRef(false);
|
|
223
|
+
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (
|
|
227
|
+
!isCurrentlyDebouncing.current &&
|
|
228
|
+
!isDeepEqual(globalStateValue, localValue)
|
|
229
|
+
) {
|
|
230
|
+
setLocalValue(globalStateValue);
|
|
231
|
+
}
|
|
232
|
+
}, [globalStateValue]);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const unsubscribe = getGlobalStore
|
|
236
|
+
.getState()
|
|
237
|
+
.subscribeToPath(stateKeyPathKey, (newValue) => {
|
|
238
|
+
if (!isCurrentlyDebouncing.current && localValue !== newValue) {
|
|
239
|
+
forceUpdate({});
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return () => {
|
|
243
|
+
unsubscribe();
|
|
244
|
+
if (debounceTimeoutRef.current) {
|
|
245
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
246
|
+
isCurrentlyDebouncing.current = false;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
const debouncedUpdate = useCallback(
|
|
252
|
+
(newValue: any) => {
|
|
253
|
+
const currentType = typeof globalStateValue;
|
|
254
|
+
if (currentType === 'number' && typeof newValue === 'string') {
|
|
255
|
+
newValue = newValue === '' ? 0 : Number(newValue);
|
|
256
|
+
}
|
|
257
|
+
setLocalValue(newValue);
|
|
258
|
+
isCurrentlyDebouncing.current = true;
|
|
259
|
+
|
|
260
|
+
if (debounceTimeoutRef.current) {
|
|
261
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const debounceTime = formOpts?.debounceTime ?? 200;
|
|
265
|
+
|
|
266
|
+
debounceTimeoutRef.current = setTimeout(() => {
|
|
267
|
+
isCurrentlyDebouncing.current = false;
|
|
268
|
+
setState(newValue, path, { updateType: 'update' });
|
|
269
|
+
|
|
270
|
+
// NEW: Check if validation is enabled via features
|
|
271
|
+
const rootMeta = getGlobalStore
|
|
272
|
+
.getState()
|
|
273
|
+
.getShadowMetadata(stateKey, []);
|
|
274
|
+
if (!rootMeta?.features?.validationEnabled) return;
|
|
275
|
+
|
|
276
|
+
const validationOptions = getInitialOptions(stateKey)?.validation;
|
|
277
|
+
const zodSchema =
|
|
278
|
+
validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
|
|
279
|
+
|
|
280
|
+
if (zodSchema) {
|
|
281
|
+
const fullState = getShadowValue(stateKey, []);
|
|
282
|
+
const result = zodSchema.safeParse(fullState);
|
|
283
|
+
const currentMeta = getShadowMetadata(stateKey, path) || {};
|
|
284
|
+
|
|
285
|
+
if (!result.success) {
|
|
286
|
+
const errors =
|
|
287
|
+
'issues' in result.error
|
|
288
|
+
? result.error.issues
|
|
289
|
+
: (result.error as any).errors;
|
|
290
|
+
|
|
291
|
+
const pathErrors = errors.filter(
|
|
292
|
+
(error: any) =>
|
|
293
|
+
JSON.stringify(error.path) === JSON.stringify(path)
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (pathErrors.length > 0) {
|
|
297
|
+
setShadowMetadata(stateKey, path, {
|
|
298
|
+
...currentMeta,
|
|
299
|
+
validation: {
|
|
300
|
+
status: 'INVALID',
|
|
301
|
+
errors: [
|
|
302
|
+
{
|
|
303
|
+
source: 'client',
|
|
304
|
+
message: pathErrors[0]?.message,
|
|
305
|
+
severity: 'warning', // Gentle error during typing
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
lastValidated: Date.now(),
|
|
309
|
+
validatedValue: newValue,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
setShadowMetadata(stateKey, path, {
|
|
314
|
+
...currentMeta,
|
|
315
|
+
validation: {
|
|
316
|
+
status: 'VALID',
|
|
317
|
+
errors: [],
|
|
318
|
+
lastValidated: Date.now(),
|
|
319
|
+
validatedValue: newValue,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
setShadowMetadata(stateKey, path, {
|
|
325
|
+
...currentMeta,
|
|
326
|
+
validation: {
|
|
327
|
+
status: 'VALID',
|
|
328
|
+
errors: [],
|
|
329
|
+
lastValidated: Date.now(),
|
|
330
|
+
validatedValue: newValue,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}, debounceTime);
|
|
336
|
+
forceUpdate({});
|
|
337
|
+
},
|
|
338
|
+
[setState, path, formOpts?.debounceTime, stateKey]
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const handleBlur = useCallback(async () => {
|
|
342
|
+
console.log('handleBlur triggered');
|
|
343
|
+
|
|
344
|
+
// Commit any pending changes
|
|
345
|
+
if (debounceTimeoutRef.current) {
|
|
346
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
347
|
+
debounceTimeoutRef.current = null;
|
|
348
|
+
isCurrentlyDebouncing.current = false;
|
|
349
|
+
setState(localValue, path, { updateType: 'update' });
|
|
350
|
+
}
|
|
351
|
+
const rootMeta = getShadowMetadata(stateKey, []);
|
|
352
|
+
if (!rootMeta?.features?.validationEnabled) return;
|
|
353
|
+
const { getInitialOptions } = getGlobalStore.getState();
|
|
354
|
+
const validationOptions = getInitialOptions(stateKey)?.validation;
|
|
355
|
+
const zodSchema =
|
|
356
|
+
validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
|
|
357
|
+
|
|
358
|
+
if (!zodSchema) return;
|
|
359
|
+
|
|
360
|
+
// Get the full path including stateKey
|
|
361
|
+
|
|
362
|
+
// Update validation state to "validating"
|
|
363
|
+
const currentMeta = getShadowMetadata(stateKey, path);
|
|
364
|
+
|
|
365
|
+
setShadowMetadata(stateKey, path, {
|
|
366
|
+
...currentMeta,
|
|
367
|
+
validation: {
|
|
368
|
+
status: 'VALIDATING',
|
|
369
|
+
errors: [],
|
|
370
|
+
lastValidated: Date.now(),
|
|
371
|
+
validatedValue: localValue,
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Validate full state
|
|
376
|
+
const fullState = getShadowValue(stateKey, []);
|
|
377
|
+
const result = zodSchema.safeParse(fullState);
|
|
378
|
+
|
|
379
|
+
if (!result.success) {
|
|
380
|
+
const errors =
|
|
381
|
+
'issues' in result.error
|
|
382
|
+
? result.error.issues
|
|
383
|
+
: (result.error as any).errors;
|
|
384
|
+
|
|
385
|
+
// Find errors for this specific path
|
|
386
|
+
const pathErrors = errors.filter((error: any) => {
|
|
387
|
+
// For array paths, we need to translate indices to ULIDs
|
|
388
|
+
if (path.some((p) => p.startsWith('id:'))) {
|
|
389
|
+
// This is an array item path like ["id:xyz", "name"]
|
|
390
|
+
const parentPath = path[0]!.startsWith('id:')
|
|
391
|
+
? []
|
|
392
|
+
: path.slice(0, -1);
|
|
393
|
+
|
|
394
|
+
const arrayMeta = getGlobalStore
|
|
395
|
+
.getState()
|
|
396
|
+
.getShadowMetadata(stateKey, parentPath);
|
|
397
|
+
|
|
398
|
+
if (arrayMeta?.arrayKeys) {
|
|
399
|
+
const itemKey = [stateKey, ...path.slice(0, -1)].join('.');
|
|
400
|
+
const itemIndex = arrayMeta.arrayKeys.indexOf(itemKey);
|
|
401
|
+
|
|
402
|
+
// Compare with Zod path
|
|
403
|
+
const zodPath = [...parentPath, itemIndex, ...path.slice(-1)];
|
|
404
|
+
const match =
|
|
405
|
+
JSON.stringify(error.path) === JSON.stringify(zodPath);
|
|
406
|
+
|
|
407
|
+
return match;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const directMatch = JSON.stringify(error.path) === JSON.stringify(path);
|
|
412
|
+
|
|
413
|
+
return directMatch;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Update shadow metadata with validation result
|
|
417
|
+
setShadowMetadata(stateKey, path, {
|
|
418
|
+
...currentMeta,
|
|
419
|
+
validation: {
|
|
420
|
+
status: 'INVALID',
|
|
421
|
+
errors: pathErrors.map((err: any) => ({
|
|
422
|
+
source: 'client' as const,
|
|
423
|
+
message: err.message,
|
|
424
|
+
severity: 'error' as const, // Hard error on blur
|
|
425
|
+
})),
|
|
426
|
+
lastValidated: Date.now(),
|
|
427
|
+
validatedValue: localValue,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
// Validation passed
|
|
432
|
+
setShadowMetadata(stateKey, path, {
|
|
433
|
+
...currentMeta,
|
|
434
|
+
validation: {
|
|
435
|
+
status: 'VALID',
|
|
436
|
+
errors: [],
|
|
437
|
+
lastValidated: Date.now(),
|
|
438
|
+
validatedValue: localValue,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
forceUpdate({});
|
|
443
|
+
}, [stateKey, path, localValue, setState]);
|
|
444
|
+
|
|
445
|
+
const baseState = rebuildStateShape({
|
|
446
|
+
path: path,
|
|
447
|
+
componentId: componentId,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const stateWithInputProps = new Proxy(baseState, {
|
|
451
|
+
get(target, prop) {
|
|
452
|
+
if (prop === 'inputProps') {
|
|
453
|
+
return {
|
|
454
|
+
value: localValue ?? '',
|
|
455
|
+
onChange: (e: any) => {
|
|
456
|
+
debouncedUpdate(e.target.value);
|
|
457
|
+
},
|
|
458
|
+
// 5. Wire the new onBlur handler to the input props.
|
|
459
|
+
onBlur: handleBlur,
|
|
460
|
+
ref: formRefStore
|
|
461
|
+
.getState()
|
|
462
|
+
.getFormRef(stateKey + '.' + path.join('.')),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return target[prop];
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<ValidationWrapper formOpts={formOpts} path={path} stateKey={stateKey}>
|
|
472
|
+
{renderFn(stateWithInputProps)}
|
|
473
|
+
</ValidationWrapper>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
export function useRegisterComponent(
|
|
477
|
+
stateKey: string,
|
|
478
|
+
componentId: string,
|
|
479
|
+
forceUpdate: (o: object) => void
|
|
480
|
+
) {
|
|
481
|
+
const fullComponentId = `${stateKey}////${componentId}`;
|
|
482
|
+
|
|
483
|
+
useLayoutEffect(() => {
|
|
484
|
+
// Call the safe, centralized function to register
|
|
485
|
+
registerComponent(stateKey, fullComponentId, {
|
|
486
|
+
forceUpdate: () => forceUpdate({}),
|
|
487
|
+
paths: new Set(),
|
|
488
|
+
reactiveType: ['component'],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// The cleanup now calls the safe, centralized unregister function
|
|
492
|
+
return () => {
|
|
493
|
+
unregisterComponent(stateKey, fullComponentId);
|
|
494
|
+
};
|
|
495
|
+
}, [stateKey, fullComponentId]); // Dependencies are stable and correct
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const useImageLoaded = (ref: RefObject<HTMLElement>): boolean => {
|
|
499
|
+
const [loaded, setLoaded] = useState(false);
|
|
500
|
+
|
|
501
|
+
useLayoutEffect(() => {
|
|
502
|
+
if (!ref.current) {
|
|
503
|
+
setLoaded(true);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const images = Array.from(ref.current.querySelectorAll('img'));
|
|
508
|
+
|
|
509
|
+
// If there are no images, we are "loaded" immediately.
|
|
510
|
+
if (images.length === 0) {
|
|
511
|
+
setLoaded(true);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let loadedCount = 0;
|
|
516
|
+
const handleImageLoad = () => {
|
|
517
|
+
loadedCount++;
|
|
518
|
+
if (loadedCount === images.length) {
|
|
519
|
+
setLoaded(true);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
images.forEach((image) => {
|
|
524
|
+
if (image.complete) {
|
|
525
|
+
handleImageLoad();
|
|
526
|
+
} else {
|
|
527
|
+
image.addEventListener('load', handleImageLoad);
|
|
528
|
+
image.addEventListener('error', handleImageLoad);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
return () => {
|
|
533
|
+
images.forEach((image) => {
|
|
534
|
+
image.removeEventListener('load', handleImageLoad);
|
|
535
|
+
image.removeEventListener('error', handleImageLoad);
|
|
536
|
+
});
|
|
537
|
+
};
|
|
538
|
+
}, [ref.current]);
|
|
539
|
+
|
|
540
|
+
return loaded;
|
|
541
|
+
};
|