@usesidekick/react 0.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 (48) hide show
  1. package/README.md +246 -0
  2. package/dist/index.d.mts +358 -0
  3. package/dist/index.d.ts +358 -0
  4. package/dist/index.js +2470 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +2403 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/jsx-dev-runtime.d.mts +21 -0
  9. package/dist/jsx-dev-runtime.d.ts +21 -0
  10. package/dist/jsx-dev-runtime.js +160 -0
  11. package/dist/jsx-dev-runtime.js.map +1 -0
  12. package/dist/jsx-dev-runtime.mjs +122 -0
  13. package/dist/jsx-dev-runtime.mjs.map +1 -0
  14. package/dist/jsx-runtime.d.mts +26 -0
  15. package/dist/jsx-runtime.d.ts +26 -0
  16. package/dist/jsx-runtime.js +150 -0
  17. package/dist/jsx-runtime.js.map +1 -0
  18. package/dist/jsx-runtime.mjs +109 -0
  19. package/dist/jsx-runtime.mjs.map +1 -0
  20. package/dist/server/index.d.mts +235 -0
  21. package/dist/server/index.d.ts +235 -0
  22. package/dist/server/index.js +642 -0
  23. package/dist/server/index.js.map +1 -0
  24. package/dist/server/index.mjs +597 -0
  25. package/dist/server/index.mjs.map +1 -0
  26. package/package.json +64 -0
  27. package/src/components/SidekickPanel.tsx +868 -0
  28. package/src/components/index.ts +1 -0
  29. package/src/context.tsx +157 -0
  30. package/src/flags.ts +47 -0
  31. package/src/index.ts +71 -0
  32. package/src/jsx-dev-runtime.ts +138 -0
  33. package/src/jsx-runtime.ts +159 -0
  34. package/src/loader.ts +35 -0
  35. package/src/primitives/behavior.ts +70 -0
  36. package/src/primitives/data.ts +91 -0
  37. package/src/primitives/index.ts +3 -0
  38. package/src/primitives/ui.ts +268 -0
  39. package/src/provider.tsx +1264 -0
  40. package/src/runtime-loader.ts +106 -0
  41. package/src/server/drizzle-adapter.ts +53 -0
  42. package/src/server/drizzle-schema.ts +16 -0
  43. package/src/server/generate.ts +578 -0
  44. package/src/server/handler.ts +343 -0
  45. package/src/server/index.ts +20 -0
  46. package/src/server/storage.ts +1 -0
  47. package/src/server/types.ts +49 -0
  48. package/src/types.ts +295 -0
@@ -0,0 +1,1264 @@
1
+ 'use client';
2
+
3
+ import React, { ReactNode, useCallback, useEffect, useMemo, useState, useRef, ComponentType } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { SidekickContext, SidekickContextValue } from './context';
6
+ import { SidekickState, Override, SDK, AddedColumn, ColumnRename, ColumnOrder, HiddenColumn, RowFilter, WrappedComponent, InjectionPoint } from './types';
7
+ import { loadFlags, setFlag as saveFlag, getFlag } from './flags';
8
+ import { getRegisteredOverrides } from './loader';
9
+ import { createUIPrimitives } from './primitives/ui';
10
+ import { createDataPrimitives } from './primitives/data';
11
+ import { createBehaviorPrimitives } from './primitives/behavior';
12
+ import { fetchAndCompileOverrides } from './runtime-loader';
13
+ import { configureJsxRuntime } from './jsx-runtime';
14
+ import { configureJsxDevRuntime } from './jsx-dev-runtime';
15
+
16
+ interface SidekickProviderProps {
17
+ children: ReactNode;
18
+ /** API endpoint to fetch overrides from (for production/DB-backed overrides) */
19
+ overridesEndpoint?: string;
20
+ }
21
+
22
+ /**
23
+ * Convert a kebab-case table ID to PascalCase component name.
24
+ * e.g., 'task-table' -> 'TaskTable'
25
+ */
26
+ function tableIdToComponentName(tableId: string): string {
27
+ return tableId
28
+ .split('-')
29
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
30
+ .join('');
31
+ }
32
+
33
+ /**
34
+ * Creates a wrapper component that auto-injects added columns into a table
35
+ * via DOM manipulation + React portals. This allows addColumn() to work
36
+ * without the target component needing any Sidekick awareness.
37
+ */
38
+ function createAutoColumnWrapper(
39
+ columns: AddedColumn[],
40
+ renames: ColumnRename[],
41
+ columnOrder?: ColumnOrder,
42
+ hiddenColumns?: HiddenColumn[],
43
+ rowFilters?: RowFilter[]
44
+ ): (Original: ComponentType) => ComponentType {
45
+ return (Original: ComponentType) => {
46
+ function AutoColumnInjector(props: Record<string, unknown>) {
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+ const [cellTargets, setCellTargets] = useState<Array<{ element: HTMLElement; taskIndex: number; colIndex: number }>>([]);
49
+ const allTasks = (props.tasks || []) as Record<string, unknown>[];
50
+
51
+ // Apply row filters to tasks before rendering
52
+ const tasks = useMemo(() => {
53
+ if (!rowFilters || rowFilters.length === 0) return allTasks;
54
+ return allTasks.filter(row =>
55
+ rowFilters.every(rf => rf.filter(row))
56
+ );
57
+ }, [allTasks]);
58
+
59
+ // Inject column headers and cell containers into the DOM
60
+ useEffect(() => {
61
+ const container = containerRef.current;
62
+ if (!container) return;
63
+ const table = container.querySelector('table');
64
+ if (!table) return;
65
+
66
+ // Clean up any previously injected elements
67
+ table.querySelectorAll('[data-sidekick-injected]').forEach(el => el.remove());
68
+ // Reset hidden columns from previous runs
69
+ table.querySelectorAll('[data-sidekick-hidden]').forEach(el => {
70
+ (el as HTMLElement).style.display = '';
71
+ el.removeAttribute('data-sidekick-hidden');
72
+ });
73
+
74
+ // Apply column renames to existing headers
75
+ if (renames.length > 0) {
76
+ const headers = table.querySelectorAll('thead th');
77
+ headers.forEach(th => {
78
+ const text = th.textContent?.trim();
79
+ if (text) {
80
+ const rename = renames.find(r => r.originalHeader === text);
81
+ if (rename) {
82
+ th.textContent = rename.newHeader;
83
+ }
84
+ }
85
+ });
86
+ }
87
+
88
+ // Reorder columns if a column order is specified
89
+ if (columnOrder) {
90
+ const headerRow = table.querySelector('thead tr');
91
+ if (headerRow) {
92
+ const ths = Array.from(headerRow.querySelectorAll('th'));
93
+ // Build mapping: header text -> original index
94
+ const headerToIndex = new Map<string, number>();
95
+ ths.forEach((th, i) => {
96
+ const text = th.textContent?.trim() ?? '';
97
+ headerToIndex.set(text, i);
98
+ });
99
+
100
+ // Build sorted index order: ordered headers first, then remaining in original order
101
+ const orderedIndices: number[] = [];
102
+ const usedIndices = new Set<number>();
103
+ for (const headerName of columnOrder.order) {
104
+ const idx = headerToIndex.get(headerName);
105
+ if (idx !== undefined) {
106
+ orderedIndices.push(idx);
107
+ usedIndices.add(idx);
108
+ }
109
+ }
110
+ // Append remaining columns in their original order
111
+ for (let i = 0; i < ths.length; i++) {
112
+ if (!usedIndices.has(i)) {
113
+ orderedIndices.push(i);
114
+ }
115
+ }
116
+
117
+ // Reorder thead ths
118
+ const sortedThs = orderedIndices.map(i => ths[i]);
119
+ for (const th of sortedThs) {
120
+ headerRow.appendChild(th);
121
+ }
122
+
123
+ // Reorder tbody tds to match
124
+ const bodyRows = table.querySelectorAll('tbody tr');
125
+ bodyRows.forEach(row => {
126
+ const tds = Array.from(row.querySelectorAll('td'));
127
+ const sortedTds = orderedIndices.map(i => tds[i]).filter(Boolean);
128
+ for (const td of sortedTds) {
129
+ row.appendChild(td);
130
+ }
131
+ });
132
+ }
133
+ }
134
+
135
+ // Hide columns
136
+ if (hiddenColumns && hiddenColumns.length > 0) {
137
+ const headers = table.querySelectorAll('thead th');
138
+ const hiddenHeaders = new Set(hiddenColumns.map(h => h.header));
139
+ const hiddenIndices = new Set<number>();
140
+
141
+ headers.forEach((th, i) => {
142
+ const text = th.textContent?.trim() ?? '';
143
+ if (hiddenHeaders.has(text)) {
144
+ (th as HTMLElement).style.display = 'none';
145
+ th.setAttribute('data-sidekick-hidden', 'true');
146
+ hiddenIndices.add(i);
147
+ }
148
+ });
149
+
150
+ if (hiddenIndices.size > 0) {
151
+ const bodyRows = table.querySelectorAll('tbody tr');
152
+ bodyRows.forEach(row => {
153
+ const tds = row.querySelectorAll('td');
154
+ tds.forEach((td, i) => {
155
+ if (hiddenIndices.has(i)) {
156
+ (td as HTMLElement).style.display = 'none';
157
+ td.setAttribute('data-sidekick-hidden', 'true');
158
+ }
159
+ });
160
+ });
161
+ }
162
+ }
163
+
164
+ // Add column headers
165
+ const headerRow = table.querySelector('thead tr');
166
+ if (headerRow) {
167
+ for (const col of columns) {
168
+ const th = document.createElement('th');
169
+ th.className = 'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider';
170
+ th.textContent = col.header;
171
+ th.setAttribute('data-sidekick-injected', 'true');
172
+ headerRow.appendChild(th);
173
+ }
174
+ }
175
+
176
+ // Add cell containers to each body row
177
+ const bodyRows = table.querySelectorAll('tbody tr');
178
+ const targets: Array<{ element: HTMLElement; taskIndex: number; colIndex: number }> = [];
179
+ bodyRows.forEach((row, rowIndex) => {
180
+ for (let colIndex = 0; colIndex < columns.length; colIndex++) {
181
+ const td = document.createElement('td');
182
+ td.className = 'px-6 py-4 whitespace-nowrap text-sm text-gray-500';
183
+ td.setAttribute('data-sidekick-injected', 'true');
184
+ row.appendChild(td);
185
+ targets.push({ element: td, taskIndex: rowIndex, colIndex });
186
+ }
187
+ });
188
+
189
+ setCellTargets(targets);
190
+
191
+ return () => {
192
+ table.querySelectorAll('[data-sidekick-injected]').forEach(el => el.remove());
193
+ table.querySelectorAll('[data-sidekick-hidden]').forEach(el => {
194
+ (el as HTMLElement).style.display = '';
195
+ el.removeAttribute('data-sidekick-hidden');
196
+ });
197
+ setCellTargets([]);
198
+ };
199
+ }, [tasks]);
200
+
201
+ // Render the original component with filtered tasks, then portal cell content into injected TDs
202
+ const filteredProps = (rowFilters && rowFilters.length > 0)
203
+ ? { ...props, tasks }
204
+ : props;
205
+ return React.createElement(React.Fragment, null,
206
+ React.createElement('div', { ref: containerRef },
207
+ React.createElement(Original, filteredProps as Record<string, unknown>)
208
+ ),
209
+ ...cellTargets.map((target, idx) => {
210
+ const task = tasks[target.taskIndex];
211
+ const col = columns[target.colIndex];
212
+ if (!task || !col) return null;
213
+
214
+ const accessor = col.accessor;
215
+ const value = typeof accessor === 'function'
216
+ ? accessor(task)
217
+ : task[accessor as string];
218
+
219
+ const content = col.render
220
+ ? col.render(value, task)
221
+ : React.createElement('span', null, String(value ?? '-'));
222
+
223
+ return createPortal(content, target.element, `sidekick-col-${idx}`);
224
+ })
225
+ );
226
+ }
227
+ AutoColumnInjector.displayName = `AutoColumnInjector(${(Original as { displayName?: string; name?: string }).displayName || (Original as { name?: string }).name || 'Component'})`;
228
+ return AutoColumnInjector as unknown as ComponentType;
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Get a component's display name from its type (function, forwardRef, memo, lazy, etc.).
234
+ * Handles React.lazy types used by Next.js for client components referenced from server components.
235
+ */
236
+ function getComponentName(type: unknown): string | null {
237
+ if (!type) return null;
238
+ if (typeof type === 'function') {
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ const fn = type as any;
241
+ return fn.displayName || fn.name || null;
242
+ }
243
+ if (typeof type === 'object' && type !== null) {
244
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
+ const obj = type as any;
246
+ if (obj.displayName) return obj.displayName;
247
+ // React.lazy: { $$typeof: Symbol(react.lazy), _payload, _init }
248
+ // When resolved, _payload.status === 'fulfilled' and _payload.value is the actual component
249
+ if (obj.$$typeof?.toString() === 'Symbol(react.lazy)') {
250
+ const payload = obj._payload;
251
+ if (payload && payload.status === 'fulfilled' && payload.value) {
252
+ return getComponentName(payload.value);
253
+ }
254
+ }
255
+ if (obj.$$typeof) {
256
+ if (obj.type) return getComponentName(obj.type);
257
+ if (obj.render) return getComponentName(obj.render);
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * Recursively walk a React element tree and apply wrappers/replacements
265
+ * to any component types that match registered overrides.
266
+ *
267
+ * This handles components whose JSX callsite is in a server component
268
+ * (and therefore bypasses the custom JSX runtime interception).
269
+ * The SidekickProvider is a client component, so it receives the
270
+ * server-rendered element tree as `children` — we transform it here
271
+ * before React renders it.
272
+ */
273
+ function transformElementTree(
274
+ node: ReactNode,
275
+ wrappers: Map<string, WrappedComponent[]>,
276
+ replacements: Map<string, ComponentType>,
277
+ wrapCache: Map<unknown, ComponentType>
278
+ ): ReactNode {
279
+ if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
280
+ return node;
281
+ }
282
+
283
+ if (Array.isArray(node)) {
284
+ let changed = false;
285
+ const result = node.map(child => {
286
+ const transformed = transformElementTree(child, wrappers, replacements, wrapCache);
287
+ if (transformed !== child) changed = true;
288
+ return transformed;
289
+ });
290
+ return changed ? result : node;
291
+ }
292
+
293
+ if (!React.isValidElement(node)) return node;
294
+
295
+ const element = node;
296
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
297
+ const originalType = element.type as any;
298
+ let newType = originalType;
299
+ let typeChanged = false;
300
+
301
+ // Check if this element's type is a component that should be wrapped/replaced
302
+ if (typeof originalType === 'function' || (typeof originalType === 'object' && originalType !== null)) {
303
+ const name = getComponentName(originalType);
304
+ if (name) {
305
+ // Resolve the actual component from lazy types for wrapping
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
+ let resolvedType: ComponentType = originalType as any;
308
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
309
+ const maybeObj = originalType as any;
310
+ if (maybeObj.$$typeof?.toString() === 'Symbol(react.lazy)' &&
311
+ maybeObj._payload?.status === 'fulfilled' && maybeObj._payload?.value) {
312
+ resolvedType = maybeObj._payload.value;
313
+ }
314
+
315
+ const replacement = replacements.get(name);
316
+ if (replacement) {
317
+ newType = replacement;
318
+ typeChanged = true;
319
+ } else {
320
+ const componentWrappers = wrappers.get(name);
321
+ if (componentWrappers && componentWrappers.length > 0) {
322
+ // Cache wrapped types to keep referential identity stable across renders
323
+ if (!wrapCache.has(resolvedType)) {
324
+ const wrapped = componentWrappers.reduce(
325
+ (comp: ComponentType, { wrapper, where: wherePredicate }) => {
326
+ if (wherePredicate) {
327
+ // Conditional wrapper: only apply when where(props) returns true
328
+ const Wrapped = wrapper(comp);
329
+ const ConditionalWrapper = (props: Record<string, unknown>) => {
330
+ if (wherePredicate(props)) {
331
+ return React.createElement(Wrapped, props);
332
+ }
333
+ return React.createElement(comp, props);
334
+ };
335
+ ConditionalWrapper.displayName = `ConditionalWrap(${(comp as unknown as { displayName?: string; name?: string }).displayName || (comp as unknown as { name?: string }).name || 'Component'})`;
336
+ return ConditionalWrapper as unknown as ComponentType;
337
+ }
338
+ return wrapper(comp);
339
+ },
340
+ resolvedType
341
+ );
342
+ wrapCache.set(resolvedType, wrapped);
343
+ }
344
+ newType = wrapCache.get(resolvedType)!;
345
+ typeChanged = true;
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ // Recursively transform children prop
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ const childrenProp = (element.props as any).children;
354
+ const transformedChildren = childrenProp != null
355
+ ? transformElementTree(childrenProp, wrappers, replacements, wrapCache)
356
+ : childrenProp;
357
+ const childrenChanged = transformedChildren !== childrenProp;
358
+
359
+ if (!typeChanged && !childrenChanged) return node;
360
+
361
+ // Rebuild the element with the new type and/or children
362
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
+ const { children: _children, ...restProps } = element.props as any;
364
+ return React.createElement(
365
+ newType,
366
+ { ...restProps, key: element.key },
367
+ transformedChildren
368
+ );
369
+ }
370
+
371
+ function createInitialState(): SidekickState {
372
+ return {
373
+ overrides: [],
374
+ // UI state
375
+ wrappers: new Map(),
376
+ replacements: new Map(),
377
+ styles: [],
378
+ columns: new Map(),
379
+ columnRenames: new Map(),
380
+ hiddenColumns: new Map(),
381
+ columnOrders: new Map(),
382
+ rowFilters: new Map(),
383
+ menuItems: new Map(),
384
+ tabs: new Map(),
385
+ propsModifiers: new Map(),
386
+ actions: new Map(),
387
+ validations: new Map(),
388
+ // Data state
389
+ computedFields: new Map(),
390
+ filters: new Map(),
391
+ transforms: new Map(),
392
+ interceptors: [],
393
+ sortOptions: new Map(),
394
+ groupByOptions: new Map(),
395
+ // Behavior state
396
+ eventHandlers: new Map(),
397
+ keyboardShortcuts: [],
398
+ routeModifiers: [],
399
+ // DOM state
400
+ domModifications: [],
401
+ injections: [],
402
+ domEventListeners: [],
403
+ };
404
+ }
405
+
406
+ export function SidekickProvider({ children, overridesEndpoint }: SidekickProviderProps) {
407
+ const [state, setState] = useState<SidekickState>(createInitialState);
408
+ const [overrides, setOverrides] = useState<Override[]>([]);
409
+ const [isReady, setIsReady] = useState(false);
410
+
411
+ // Store refs for interceptor getters to avoid stale closures
412
+ const stateRef = useRef(state);
413
+ stateRef.current = state;
414
+
415
+ // Initialize overrides AND configure JSX runtime, then render children
416
+ useEffect(() => {
417
+ const initializeOverrides = async () => {
418
+ const newState = createInitialState();
419
+ const loadedOverrides: Override[] = [];
420
+
421
+ if (overridesEndpoint) {
422
+ // Load overrides from API
423
+ console.log('[Sidekick] Fetching overrides from:', overridesEndpoint);
424
+ const apiOverrides = await fetchAndCompileOverrides(overridesEndpoint);
425
+ console.log('[Sidekick] Fetched overrides:', apiOverrides.length);
426
+
427
+ for (const override of apiOverrides) {
428
+ loadedOverrides.push({
429
+ id: override.id,
430
+ name: override.name,
431
+ description: override.description,
432
+ version: '1.0.0',
433
+ enabled: override.enabled,
434
+ });
435
+
436
+ // Activate if enabled
437
+ if (override.enabled) {
438
+ console.log(`[Sidekick] Activating override: ${override.id}`);
439
+ const sdk: SDK = {
440
+ ui: createUIPrimitives(newState, override.id),
441
+ data: createDataPrimitives(newState, override.id),
442
+ behavior: createBehaviorPrimitives(newState, override.id),
443
+ };
444
+ try {
445
+ override.activate(sdk);
446
+ console.log(`[Sidekick] Activated override: ${override.id}`);
447
+ } catch (error) {
448
+ console.error(`[Sidekick] Failed to activate override ${override.id}:`, error);
449
+ }
450
+ }
451
+ }
452
+ } else {
453
+ // Load overrides from static registry (development mode)
454
+ const registeredOverrides = getRegisteredOverrides();
455
+ const flags = loadFlags();
456
+
457
+ for (const module of registeredOverrides) {
458
+ loadedOverrides.push({
459
+ id: module.manifest.id,
460
+ name: module.manifest.name,
461
+ description: module.manifest.description,
462
+ version: module.manifest.version,
463
+ enabled: flags[module.manifest.id] ?? false,
464
+ });
465
+ }
466
+
467
+ // Activate enabled overrides
468
+ for (const module of registeredOverrides) {
469
+ const isEnabled = flags[module.manifest.id] ?? false;
470
+ if (isEnabled) {
471
+ console.log(`[Sidekick] Activating override: ${module.manifest.id}`);
472
+ const sdk: SDK = {
473
+ ui: createUIPrimitives(newState, module.manifest.id),
474
+ data: createDataPrimitives(newState, module.manifest.id),
475
+ behavior: createBehaviorPrimitives(newState, module.manifest.id),
476
+ };
477
+ await module.activate(sdk);
478
+ console.log(`[Sidekick] After activation, wrappers:`, Array.from(newState.wrappers.keys()));
479
+ }
480
+ }
481
+ }
482
+
483
+ newState.overrides = loadedOverrides;
484
+
485
+ // Auto-create wrappers for tables with any modifications
486
+ // (added columns, renames, reorders, hidden columns, row filters)
487
+ // This enables addColumn/renameColumn/reorderColumns/hideColumn/filterRows to work
488
+ // without app components being Sidekick-aware
489
+ const tableIdsWithMods = new Set<string>();
490
+ for (const tableId of newState.columns.keys()) tableIdsWithMods.add(tableId);
491
+ for (const tableId of newState.columnRenames.keys()) tableIdsWithMods.add(tableId);
492
+ for (const tableId of newState.columnOrders.keys()) tableIdsWithMods.add(tableId);
493
+ for (const tableId of newState.hiddenColumns.keys()) tableIdsWithMods.add(tableId);
494
+ for (const tableId of newState.rowFilters.keys()) tableIdsWithMods.add(tableId);
495
+
496
+ for (const tableId of tableIdsWithMods) {
497
+ const cols = newState.columns.get(tableId) ?? [];
498
+ const tableRenames = newState.columnRenames.get(tableId) ?? [];
499
+ const tableOrder = newState.columnOrders.get(tableId);
500
+ const tableHidden = newState.hiddenColumns.get(tableId) ?? [];
501
+ const tableRowFilters = newState.rowFilters.get(tableId) ?? [];
502
+
503
+ // Skip if there are truly no modifications
504
+ if (cols.length === 0 && tableRenames.length === 0 && !tableOrder && tableHidden.length === 0 && tableRowFilters.length === 0) continue;
505
+
506
+ const componentName = tableIdToComponentName(tableId);
507
+ console.log(`[Sidekick] Auto-wrapping ${componentName} with ${cols.length} added columns, ${tableRenames.length} renames, ${tableOrder ? 'reorder' : 'no reorder'}, ${tableHidden.length} hidden, ${tableRowFilters.length} row filters`);
508
+
509
+ const wrapped: WrappedComponent = {
510
+ id: `auto-columns-${tableId}`,
511
+ overrideId: '__sidekick-internal__',
512
+ wrapper: createAutoColumnWrapper(cols, tableRenames, tableOrder, tableHidden, tableRowFilters),
513
+ priority: -1000, // Low priority - runs after user wrappers
514
+ };
515
+ const existing = newState.wrappers.get(componentName) ?? [];
516
+ existing.push(wrapped);
517
+ existing.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
518
+ newState.wrappers.set(componentName, existing);
519
+ }
520
+
521
+ setState(newState);
522
+ stateRef.current = newState;
523
+ setOverrides(loadedOverrides);
524
+
525
+ // Configure JSX runtime AFTER overrides are loaded and state is set
526
+ const wrapperGetter = (name: string): ((Component: ComponentType) => ComponentType) | undefined => {
527
+ const wrappers = stateRef.current.wrappers.get(name);
528
+ if (!wrappers || wrappers.length === 0) return undefined;
529
+ return (Component: ComponentType) => {
530
+ return wrappers.reduce(
531
+ (wrapped, { wrapper, where: wherePredicate }) => {
532
+ if (wherePredicate) {
533
+ const Wrapped = wrapper(wrapped);
534
+ const ConditionalWrapper = (props: Record<string, unknown>) => {
535
+ if (wherePredicate(props)) {
536
+ return React.createElement(Wrapped, props);
537
+ }
538
+ return React.createElement(wrapped, props);
539
+ };
540
+ ConditionalWrapper.displayName = `ConditionalWrap(${(wrapped as unknown as { displayName?: string; name?: string }).displayName || (wrapped as unknown as { name?: string }).name || 'Component'})`;
541
+ return ConditionalWrapper as unknown as ComponentType;
542
+ }
543
+ return wrapper(wrapped);
544
+ },
545
+ Component
546
+ );
547
+ };
548
+ };
549
+
550
+ const replacementGetter = (name: string): ComponentType | undefined => {
551
+ return stateRef.current.replacements.get(name);
552
+ };
553
+
554
+ console.log('[Sidekick] Configuring JSX runtime with', newState.wrappers.size, 'wrappers,', newState.replacements.size, 'replacements,', newState.columns.size, 'column sets');
555
+ configureJsxRuntime(wrapperGetter, replacementGetter);
556
+ configureJsxDevRuntime(wrapperGetter, replacementGetter);
557
+ console.log('[Sidekick] JSX runtime configured');
558
+
559
+ // NOW allow children to render
560
+ setIsReady(true);
561
+ };
562
+
563
+ initializeOverrides();
564
+ }, [overridesEndpoint]);
565
+
566
+ // Inject styles into document
567
+ useEffect(() => {
568
+ if (!isReady || typeof document === 'undefined') return;
569
+
570
+ const styleId = 'sidekick-injected-styles';
571
+ let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
572
+
573
+ if (!styleEl) {
574
+ styleEl = document.createElement('style');
575
+ styleEl.id = styleId;
576
+ document.head.appendChild(styleEl);
577
+ }
578
+
579
+ styleEl.textContent = state.styles.map((s) => s.css).join('\n');
580
+
581
+ return () => {
582
+ if (styleEl && styleEl.parentNode) {
583
+ styleEl.parentNode.removeChild(styleEl);
584
+ }
585
+ };
586
+ }, [state.styles, isReady]);
587
+
588
+ // Update JSX runtime getters when state changes (after initial load)
589
+ useEffect(() => {
590
+ if (!isReady) return;
591
+
592
+ const wrapperGetter = (name: string): ((Component: ComponentType) => ComponentType) | undefined => {
593
+ const wrappers = state.wrappers.get(name);
594
+ if (!wrappers || wrappers.length === 0) return undefined;
595
+
596
+ return (Component: ComponentType) => {
597
+ return wrappers.reduce(
598
+ (wrapped, { wrapper, where: wherePredicate }) => {
599
+ if (wherePredicate) {
600
+ const Wrapped = wrapper(wrapped);
601
+ const ConditionalWrapper = (props: Record<string, unknown>) => {
602
+ if (wherePredicate(props)) {
603
+ return React.createElement(Wrapped, props);
604
+ }
605
+ return React.createElement(wrapped, props);
606
+ };
607
+ ConditionalWrapper.displayName = `ConditionalWrap(${(wrapped as unknown as { displayName?: string; name?: string }).displayName || (wrapped as unknown as { name?: string }).name || 'Component'})`;
608
+ return ConditionalWrapper as unknown as ComponentType;
609
+ }
610
+ return wrapper(wrapped);
611
+ },
612
+ Component
613
+ );
614
+ };
615
+ };
616
+
617
+ const replacementGetter = (name: string): ComponentType | undefined => {
618
+ return state.replacements.get(name);
619
+ };
620
+
621
+ configureJsxRuntime(wrapperGetter, replacementGetter);
622
+ configureJsxDevRuntime(wrapperGetter, replacementGetter);
623
+ }, [state.wrappers, state.replacements, isReady]);
624
+
625
+ // --- DOM Modifications (MutationObserver) ---
626
+ useEffect(() => {
627
+ if (!isReady || typeof document === 'undefined' || state.domModifications.length === 0) return;
628
+
629
+ // Track original values for cleanup
630
+ const originals = new Map<Element, { type: string; value: unknown }>();
631
+
632
+ function applyModifications() {
633
+ for (const mod of state.domModifications) {
634
+ let elements: NodeListOf<Element>;
635
+ try {
636
+ elements = document.querySelectorAll(mod.selector);
637
+ } catch {
638
+ console.warn(`[Sidekick] Invalid CSS selector: "${mod.selector}"`);
639
+ continue;
640
+ }
641
+ elements.forEach((el) => {
642
+ const htmlEl = el as HTMLElement;
643
+ // Store original value on first application
644
+ if (!originals.has(el)) {
645
+ switch (mod.type) {
646
+ case 'setText': {
647
+ // Collect original text node values (not textContent, which would lose references)
648
+ const textNodes: { node: Text; value: string }[] = [];
649
+ const tw = document.createTreeWalker(htmlEl, NodeFilter.SHOW_TEXT);
650
+ let tn: Text | null;
651
+ while ((tn = tw.nextNode() as Text | null)) {
652
+ textNodes.push({ node: tn, value: tn.nodeValue || '' });
653
+ }
654
+ originals.set(el, { type: 'setText', value: textNodes });
655
+ break;
656
+ }
657
+ case 'setAttribute': {
658
+ const { attr } = mod.value as { attr: string; value: string };
659
+ originals.set(el, { type: 'setAttribute', value: { attr, value: htmlEl.getAttribute(attr) } });
660
+ break;
661
+ }
662
+ case 'setStyle':
663
+ originals.set(el, { type: 'setStyle', value: htmlEl.style.cssText });
664
+ break;
665
+ case 'addClass':
666
+ originals.set(el, { type: 'addClass', value: mod.value });
667
+ break;
668
+ case 'removeClass':
669
+ originals.set(el, { type: 'removeClass', value: mod.value });
670
+ break;
671
+ }
672
+ }
673
+ // Apply the modification
674
+ switch (mod.type) {
675
+ case 'setText': {
676
+ // Modify existing text nodes in-place to avoid destroying React's DOM references.
677
+ // Using textContent would remove all child nodes, breaking React reconciliation.
678
+ const walker = document.createTreeWalker(htmlEl, NodeFilter.SHOW_TEXT);
679
+ const firstTextNode = walker.nextNode() as Text | null;
680
+ if (firstTextNode) {
681
+ firstTextNode.nodeValue = mod.value as string;
682
+ // Clear any remaining text nodes
683
+ let extra: Text | null;
684
+ while ((extra = walker.nextNode() as Text | null)) {
685
+ extra.nodeValue = '';
686
+ }
687
+ }
688
+ break;
689
+ }
690
+ case 'setAttribute': {
691
+ const { attr, value } = mod.value as { attr: string; value: string };
692
+ htmlEl.setAttribute(attr, value);
693
+ break;
694
+ }
695
+ case 'setStyle':
696
+ Object.assign(htmlEl.style, mod.value as Record<string, string>);
697
+ break;
698
+ case 'addClass':
699
+ htmlEl.classList.add(mod.value as string);
700
+ break;
701
+ case 'removeClass':
702
+ htmlEl.classList.remove(mod.value as string);
703
+ break;
704
+ }
705
+ htmlEl.setAttribute('data-sidekick-dom-mod', 'true');
706
+ });
707
+ }
708
+ }
709
+
710
+ applyModifications();
711
+
712
+ const observer = new MutationObserver(() => {
713
+ applyModifications();
714
+ });
715
+ if (document.body) {
716
+ observer.observe(document.body, { childList: true, subtree: true });
717
+ }
718
+
719
+ return () => {
720
+ observer.disconnect();
721
+ // Restore original values
722
+ originals.forEach((original, el) => {
723
+ const htmlEl = el as HTMLElement;
724
+ switch (original.type) {
725
+ case 'setText': {
726
+ const textNodes = original.value as { node: Text; value: string }[];
727
+ for (const { node, value } of textNodes) {
728
+ try { node.nodeValue = value; } catch { /* node may have been removed by React */ }
729
+ }
730
+ break;
731
+ }
732
+ case 'setAttribute': {
733
+ const { attr, value } = original.value as { attr: string; value: string | null };
734
+ if (value === null) htmlEl.removeAttribute(attr);
735
+ else htmlEl.setAttribute(attr, value);
736
+ break;
737
+ }
738
+ case 'setStyle':
739
+ htmlEl.style.cssText = original.value as string;
740
+ break;
741
+ case 'addClass':
742
+ htmlEl.classList.remove(original.value as string);
743
+ break;
744
+ case 'removeClass':
745
+ htmlEl.classList.add(original.value as string);
746
+ break;
747
+ }
748
+ htmlEl.removeAttribute('data-sidekick-dom-mod');
749
+ });
750
+ };
751
+ }, [state.domModifications, isReady]);
752
+
753
+ // --- Keyboard Shortcuts (keydown listener) ---
754
+ useEffect(() => {
755
+ if (!isReady || typeof document === 'undefined' || state.keyboardShortcuts.length === 0) return;
756
+
757
+ function matchesKeys(event: KeyboardEvent, keys: string): boolean {
758
+ const parts = keys.toLowerCase().split('+').map(s => s.trim());
759
+ const keyName = parts[parts.length - 1];
760
+ const modifiers = new Set(parts.slice(0, -1));
761
+
762
+ const needsCtrl = modifiers.has('ctrl') || modifiers.has('cmd') || modifiers.has('meta');
763
+ const needsShift = modifiers.has('shift');
764
+ const needsAlt = modifiers.has('alt');
765
+
766
+ if (needsCtrl !== (event.ctrlKey || event.metaKey)) return false;
767
+ if (needsShift !== event.shiftKey) return false;
768
+ if (needsAlt !== event.altKey) return false;
769
+
770
+ return event.key.toLowerCase() === keyName || event.code.toLowerCase() === keyName;
771
+ }
772
+
773
+ function handleKeydown(event: KeyboardEvent) {
774
+ for (const shortcut of state.keyboardShortcuts) {
775
+ if (matchesKeys(event, shortcut.keys)) {
776
+ event.preventDefault();
777
+ try {
778
+ shortcut.action();
779
+ } catch (error) {
780
+ console.error(`[Sidekick] Keyboard shortcut error for "${shortcut.keys}":`, error);
781
+ }
782
+ return;
783
+ }
784
+ }
785
+ }
786
+
787
+ document.addEventListener('keydown', handleKeydown);
788
+ return () => {
789
+ document.removeEventListener('keydown', handleKeydown);
790
+ };
791
+ }, [state.keyboardShortcuts, isReady]);
792
+
793
+ // --- Fetch Interception (monkey-patch window.fetch) ---
794
+ useEffect(() => {
795
+ if (!isReady || typeof window === 'undefined' || state.interceptors.length === 0) return;
796
+
797
+ const originalFetch = window.fetch;
798
+
799
+ window.fetch = async function sidekickFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
800
+ const response = await originalFetch.call(window, input, init);
801
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
802
+ const method = init?.method || 'GET';
803
+
804
+ for (const interceptor of stateRef.current.interceptors) {
805
+ const pattern = interceptor.pathPattern;
806
+ const matches = typeof pattern === 'string'
807
+ ? url.includes(pattern)
808
+ : pattern.test(url);
809
+
810
+ if (matches) {
811
+ try {
812
+ const cloned = response.clone();
813
+ const json = await cloned.json();
814
+ const modified = interceptor.handler(json, { path: url, method });
815
+ return new Response(JSON.stringify(modified), {
816
+ status: response.status,
817
+ statusText: response.statusText,
818
+ headers: response.headers,
819
+ });
820
+ } catch {
821
+ // If JSON parsing fails or handler errors, return original
822
+ return response;
823
+ }
824
+ }
825
+ }
826
+
827
+ return response;
828
+ };
829
+
830
+ return () => {
831
+ window.fetch = originalFetch;
832
+ };
833
+ }, [state.interceptors, isReady]);
834
+
835
+ // --- DOM Injections (MutationObserver + Portals) ---
836
+ const [injectionContainers, setInjectionContainers] = useState<Array<{ injection: InjectionPoint; container: HTMLElement }>>([]);
837
+
838
+ useEffect(() => {
839
+ if (!isReady || typeof document === 'undefined' || state.injections.length === 0) return;
840
+
841
+ const containers: Array<{ injection: InjectionPoint; container: HTMLElement }> = [];
842
+
843
+ function createContainers() {
844
+ // Clean up previous containers
845
+ document.querySelectorAll('[data-sidekick-injection]').forEach(el => el.remove());
846
+ containers.length = 0;
847
+
848
+ for (const injection of state.injections) {
849
+ let target: Element | null;
850
+ try {
851
+ target = document.querySelector(injection.selector);
852
+ } catch {
853
+ console.warn(`[Sidekick] Invalid CSS selector: "${injection.selector}"`);
854
+ continue;
855
+ }
856
+ if (!target) continue;
857
+
858
+ const container = document.createElement('div');
859
+ container.setAttribute('data-sidekick-injection', injection.id);
860
+
861
+ switch (injection.position) {
862
+ case 'before':
863
+ target.parentNode?.insertBefore(container, target);
864
+ break;
865
+ case 'after':
866
+ target.parentNode?.insertBefore(container, target.nextSibling);
867
+ break;
868
+ case 'prepend':
869
+ target.insertBefore(container, target.firstChild);
870
+ break;
871
+ case 'append':
872
+ target.appendChild(container);
873
+ break;
874
+ }
875
+
876
+ containers.push({ injection, container });
877
+ }
878
+
879
+ setInjectionContainers([...containers]);
880
+ }
881
+
882
+ createContainers();
883
+
884
+ const observer = new MutationObserver(() => {
885
+ // Re-check if any targets appeared
886
+ const needsUpdate = state.injections.some(inj => {
887
+ try {
888
+ const existing = document.querySelector(`[data-sidekick-injection="${inj.id}"]`);
889
+ if (existing) return false;
890
+ const target = document.querySelector(inj.selector);
891
+ return !!target;
892
+ } catch {
893
+ return false;
894
+ }
895
+ });
896
+ if (needsUpdate) createContainers();
897
+ });
898
+ if (document.body) {
899
+ observer.observe(document.body, { childList: true, subtree: true });
900
+ }
901
+
902
+ return () => {
903
+ observer.disconnect();
904
+ document.querySelectorAll('[data-sidekick-injection]').forEach(el => el.remove());
905
+ setInjectionContainers([]);
906
+ };
907
+ }, [state.injections, isReady]);
908
+
909
+ // --- DOM Event Delegation ---
910
+ useEffect(() => {
911
+ if (!isReady || typeof document === 'undefined' || state.domEventListeners.length === 0) return;
912
+
913
+ const cleanups: Array<() => void> = [];
914
+
915
+ for (const listener of state.domEventListeners) {
916
+ const handler = (event: Event) => {
917
+ const target = event.target as Element | null;
918
+ if (!target) return;
919
+ let matched: Element | null;
920
+ try {
921
+ matched = target.matches(listener.selector) ? target : target.closest(listener.selector);
922
+ } catch {
923
+ return; // Invalid selector
924
+ }
925
+ if (matched) {
926
+ try {
927
+ listener.handler(event, matched);
928
+ } catch (error) {
929
+ console.error(`[Sidekick] DOM event handler error for "${listener.selector}" ${listener.eventType}:`, error);
930
+ }
931
+ }
932
+ };
933
+ document.addEventListener(listener.eventType, handler);
934
+ cleanups.push(() => document.removeEventListener(listener.eventType, handler));
935
+ }
936
+
937
+ return () => {
938
+ cleanups.forEach(cleanup => cleanup());
939
+ };
940
+ }, [state.domEventListeners, isReady]);
941
+
942
+ // --- Route Modification (history patch) ---
943
+ useEffect(() => {
944
+ if (!isReady || typeof window === 'undefined' || state.routeModifiers.length === 0) return;
945
+
946
+ const originalPushState = history.pushState.bind(history);
947
+ const originalReplaceState = history.replaceState.bind(history);
948
+
949
+ function interceptNavigation(
950
+ original: typeof history.pushState,
951
+ data: unknown,
952
+ unused: string,
953
+ url?: string | URL | null
954
+ ) {
955
+ let finalUrl = url;
956
+ if (url) {
957
+ const urlStr = url.toString();
958
+ for (const modifier of stateRef.current.routeModifiers) {
959
+ const pattern = modifier.pathPattern;
960
+ const matches = typeof pattern === 'string'
961
+ ? urlStr.includes(pattern)
962
+ : pattern.test(urlStr);
963
+
964
+ if (matches) {
965
+ try {
966
+ const result = modifier.handler({ path: urlStr, params: {} });
967
+ if (typeof result === 'string') {
968
+ finalUrl = result;
969
+ }
970
+ } catch (error) {
971
+ console.error(`[Sidekick] Route modifier error:`, error);
972
+ }
973
+ }
974
+ }
975
+ }
976
+ original(data, unused, finalUrl);
977
+ }
978
+
979
+ history.pushState = function(data: unknown, unused: string, url?: string | URL | null) {
980
+ interceptNavigation(originalPushState, data, unused, url);
981
+ };
982
+
983
+ history.replaceState = function(data: unknown, unused: string, url?: string | URL | null) {
984
+ interceptNavigation(originalReplaceState, data, unused, url);
985
+ };
986
+
987
+ const handlePopState = () => {
988
+ const currentPath = window.location.pathname;
989
+ for (const modifier of stateRef.current.routeModifiers) {
990
+ const pattern = modifier.pathPattern;
991
+ const matches = typeof pattern === 'string'
992
+ ? currentPath.includes(pattern)
993
+ : pattern.test(currentPath);
994
+
995
+ if (matches) {
996
+ try {
997
+ modifier.handler({ path: currentPath, params: {} });
998
+ } catch (error) {
999
+ console.error(`[Sidekick] Route modifier error on popstate:`, error);
1000
+ }
1001
+ }
1002
+ }
1003
+ };
1004
+
1005
+ window.addEventListener('popstate', handlePopState);
1006
+
1007
+ return () => {
1008
+ history.pushState = originalPushState;
1009
+ history.replaceState = originalReplaceState;
1010
+ window.removeEventListener('popstate', handlePopState);
1011
+ };
1012
+ }, [state.routeModifiers, isReady]);
1013
+
1014
+ const isOverrideEnabled = useCallback(
1015
+ (id: string) => {
1016
+ // For API-based overrides, check the loaded state
1017
+ const override = overrides.find(o => o.id === id);
1018
+ if (override) return override.enabled;
1019
+ // Fallback to localStorage
1020
+ return getFlag(id);
1021
+ },
1022
+ [overrides]
1023
+ );
1024
+
1025
+ const toggleOverride = useCallback(
1026
+ (id: string) => {
1027
+ const currentEnabled = getFlag(id);
1028
+ saveFlag(id, !currentEnabled);
1029
+ // Trigger re-initialization
1030
+ window.location.reload();
1031
+ },
1032
+ []
1033
+ );
1034
+
1035
+ const getColumns = useCallback(
1036
+ (tableId: string): AddedColumn[] => {
1037
+ return state.columns.get(tableId) ?? [];
1038
+ },
1039
+ [state.columns]
1040
+ );
1041
+
1042
+ const getColumnRenames = useCallback(
1043
+ (tableId: string): ColumnRename[] => {
1044
+ return state.columnRenames.get(tableId) ?? [];
1045
+ },
1046
+ [state.columnRenames]
1047
+ );
1048
+
1049
+ const getHiddenColumns = useCallback(
1050
+ (tableId: string) => {
1051
+ return state.hiddenColumns.get(tableId) ?? [];
1052
+ },
1053
+ [state.hiddenColumns]
1054
+ );
1055
+
1056
+ const getColumnOrder = useCallback(
1057
+ (tableId: string) => {
1058
+ return state.columnOrders.get(tableId);
1059
+ },
1060
+ [state.columnOrders]
1061
+ );
1062
+
1063
+ const getMenuItems = useCallback(
1064
+ (menuId: string) => {
1065
+ return state.menuItems.get(menuId) ?? [];
1066
+ },
1067
+ [state.menuItems]
1068
+ );
1069
+
1070
+ const getTabs = useCallback(
1071
+ (tabGroupId: string) => {
1072
+ return state.tabs.get(tabGroupId) ?? [];
1073
+ },
1074
+ [state.tabs]
1075
+ );
1076
+
1077
+ const getActions = useCallback(
1078
+ (actionBarId: string) => {
1079
+ return state.actions.get(actionBarId) ?? [];
1080
+ },
1081
+ [state.actions]
1082
+ );
1083
+
1084
+ const getValidations = useCallback(
1085
+ (formId: string) => {
1086
+ return state.validations.get(formId) ?? [];
1087
+ },
1088
+ [state.validations]
1089
+ );
1090
+
1091
+ const applyPropsModifiers = useCallback(
1092
+ (componentId: string, props: Record<string, unknown>) => {
1093
+ const modifiers = state.propsModifiers.get(componentId) ?? [];
1094
+ return modifiers.reduce((acc, { modifier }) => modifier(acc), props);
1095
+ },
1096
+ [state.propsModifiers]
1097
+ );
1098
+
1099
+ const getComputedValue = useCallback(
1100
+ <T,>(fieldName: string, data: Record<string, unknown>): T | undefined => {
1101
+ const field = state.computedFields.get(fieldName);
1102
+ if (!field) return undefined;
1103
+ return field.compute(data) as T;
1104
+ },
1105
+ [state.computedFields]
1106
+ );
1107
+
1108
+ const applyFilters = useCallback(
1109
+ <T,>(filterName: string, items: T[]): T[] => {
1110
+ const filter = state.filters.get(filterName);
1111
+ if (!filter) return items;
1112
+ return filter.filter(items as unknown[]) as T[];
1113
+ },
1114
+ [state.filters]
1115
+ );
1116
+
1117
+ const applyTransform = useCallback(
1118
+ <T,>(dataKey: string, data: T): T => {
1119
+ const transform = state.transforms.get(dataKey);
1120
+ if (!transform) return data;
1121
+ return transform.transform(data) as T;
1122
+ },
1123
+ [state.transforms]
1124
+ );
1125
+
1126
+ const getSortOptions = useCallback(
1127
+ (tableId: string) => {
1128
+ return state.sortOptions.get(tableId) ?? [];
1129
+ },
1130
+ [state.sortOptions]
1131
+ );
1132
+
1133
+ const getGroupByOptions = useCallback(
1134
+ (tableId: string) => {
1135
+ return state.groupByOptions.get(tableId) ?? [];
1136
+ },
1137
+ [state.groupByOptions]
1138
+ );
1139
+
1140
+ const getKeyboardShortcuts = useCallback(
1141
+ () => {
1142
+ return state.keyboardShortcuts;
1143
+ },
1144
+ [state.keyboardShortcuts]
1145
+ );
1146
+
1147
+ const emitEvent = useCallback(
1148
+ (eventName: string, payload: Record<string, unknown>) => {
1149
+ const handlers = state.eventHandlers.get(eventName) ?? [];
1150
+ for (const handler of handlers) {
1151
+ try {
1152
+ handler.handler(payload);
1153
+ } catch (error) {
1154
+ console.error(`[Sidekick] Event handler error for ${eventName}:`, error);
1155
+ }
1156
+ }
1157
+ },
1158
+ [state.eventHandlers]
1159
+ );
1160
+
1161
+ const getWrapper = useCallback(
1162
+ (componentName: string): ((Component: ComponentType) => ComponentType) | undefined => {
1163
+ const wrappers = state.wrappers.get(componentName);
1164
+ if (!wrappers || wrappers.length === 0) return undefined;
1165
+
1166
+ // Compose all wrappers into a single wrapper function
1167
+ return (Component: ComponentType) => {
1168
+ return wrappers.reduce(
1169
+ (wrapped, { wrapper }) => wrapper(wrapped),
1170
+ Component
1171
+ );
1172
+ };
1173
+ },
1174
+ [state.wrappers]
1175
+ );
1176
+
1177
+ const getReplacement = useCallback(
1178
+ (componentName: string): ComponentType | undefined => {
1179
+ return state.replacements.get(componentName);
1180
+ },
1181
+ [state.replacements]
1182
+ );
1183
+
1184
+ const contextValue: SidekickContextValue = useMemo(
1185
+ () => ({
1186
+ state,
1187
+ overrides,
1188
+ isOverrideEnabled,
1189
+ toggleOverride,
1190
+ // UI
1191
+ getColumns,
1192
+ getColumnRenames,
1193
+ getHiddenColumns,
1194
+ getColumnOrder,
1195
+ getMenuItems,
1196
+ getTabs,
1197
+ getActions,
1198
+ getValidations,
1199
+ getWrapper,
1200
+ getReplacement,
1201
+ applyPropsModifiers,
1202
+ // Data
1203
+ getComputedValue,
1204
+ applyFilters,
1205
+ applyTransform,
1206
+ getSortOptions,
1207
+ getGroupByOptions,
1208
+ // Behavior
1209
+ getKeyboardShortcuts,
1210
+ emitEvent,
1211
+ }),
1212
+ [
1213
+ state,
1214
+ overrides,
1215
+ isOverrideEnabled,
1216
+ toggleOverride,
1217
+ getColumns,
1218
+ getColumnRenames,
1219
+ getHiddenColumns,
1220
+ getColumnOrder,
1221
+ getMenuItems,
1222
+ getTabs,
1223
+ getActions,
1224
+ getValidations,
1225
+ getWrapper,
1226
+ getReplacement,
1227
+ applyPropsModifiers,
1228
+ getComputedValue,
1229
+ applyFilters,
1230
+ applyTransform,
1231
+ getSortOptions,
1232
+ getGroupByOptions,
1233
+ getKeyboardShortcuts,
1234
+ emitEvent,
1235
+ ]
1236
+ );
1237
+
1238
+ // Cache for wrapped component types — persisted across renders via ref
1239
+ // so React sees stable component identities and doesn't remount
1240
+ const wrapCacheRef = useRef(new Map<unknown, ComponentType>());
1241
+
1242
+ // Transform the children element tree to apply wrappers/replacements
1243
+ // to components whose JSX callsite is in a server component (and therefore
1244
+ // bypasses the custom JSX runtime interception).
1245
+ const transformedChildren = useMemo(() => {
1246
+ if (!isReady) return null;
1247
+ if (state.wrappers.size === 0 && state.replacements.size === 0) return children;
1248
+ return transformElementTree(children, state.wrappers, state.replacements, wrapCacheRef.current);
1249
+ }, [children, isReady, state.wrappers, state.replacements]);
1250
+
1251
+ // Don't render children until overrides are loaded and JSX runtime is configured
1252
+ return (
1253
+ <SidekickContext.Provider value={contextValue}>
1254
+ {isReady ? transformedChildren : null}
1255
+ {injectionContainers.map(({ injection, container }) =>
1256
+ createPortal(
1257
+ React.createElement(injection.component, {}),
1258
+ container,
1259
+ `sidekick-injection-${injection.id}`
1260
+ )
1261
+ )}
1262
+ </SidekickContext.Provider>
1263
+ );
1264
+ }