@procedures/splay 0.0.0-stage → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/components/define.d.ts +134 -0
  6. package/dist/components/define.d.ts.map +1 -0
  7. package/dist/components/define.js +486 -0
  8. package/dist/components/define.js.map +1 -0
  9. package/dist/components/index.d.ts +39 -0
  10. package/dist/components/index.d.ts.map +1 -0
  11. package/dist/components/index.js +38 -0
  12. package/dist/components/index.js.map +1 -0
  13. package/dist/components/types.d.ts +264 -0
  14. package/dist/components/types.d.ts.map +1 -0
  15. package/dist/components/types.js +46 -0
  16. package/dist/components/types.js.map +1 -0
  17. package/dist/hydrate.d.ts +126 -0
  18. package/dist/hydrate.d.ts.map +1 -0
  19. package/dist/hydrate.js +226 -0
  20. package/dist/hydrate.js.map +1 -0
  21. package/dist/index.d.ts +40 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +53 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/module.d.ts +24 -0
  26. package/dist/module.d.ts.map +1 -0
  27. package/dist/module.js +68 -0
  28. package/dist/module.js.map +1 -0
  29. package/dist/register.d.ts +7 -0
  30. package/dist/register.d.ts.map +1 -0
  31. package/dist/register.js +9 -0
  32. package/dist/register.js.map +1 -0
  33. package/dist/registry.d.ts +60 -0
  34. package/dist/registry.d.ts.map +1 -0
  35. package/dist/registry.js +191 -0
  36. package/dist/registry.js.map +1 -0
  37. package/dist/streaming.d.ts +94 -0
  38. package/dist/streaming.d.ts.map +1 -0
  39. package/dist/streaming.js +422 -0
  40. package/dist/streaming.js.map +1 -0
  41. package/dist/types.d.ts +85 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +7 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +76 -3
  46. package/src/components/define.ts +600 -0
  47. package/src/components/index.ts +78 -0
  48. package/src/components/types.ts +357 -0
  49. package/src/hydrate.ts +355 -0
  50. package/src/index.ts +110 -0
  51. package/src/module.ts +93 -0
  52. package/src/register.ts +11 -0
  53. package/src/registry.ts +257 -0
  54. package/src/streaming.ts +554 -0
  55. package/src/types.ts +114 -0
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Streaming Registry
3
+ *
4
+ * Creates a splay-compatible StreamingRegistry for components that
5
+ * yield multiple outputs over time (live updates, progressive rendering).
6
+ */
7
+
8
+ import type { ComponentOutput } from "./components/index.js";
9
+ import type {
10
+ StreamingRegistry,
11
+ StreamingComponentRenderer,
12
+ RenderContext,
13
+ StreamingRegistryOptions,
14
+ } from "./types.js";
15
+
16
+ // Portable timer types (cross-platform)
17
+ type TimerFn = (callback: () => void, ms: number) => number;
18
+ type ClearFn = (id: number) => void;
19
+ type GlobalWithTimers = { setTimeout: TimerFn; clearTimeout: ClearFn };
20
+ type TimerId = number;
21
+ const setTimer = (globalThis as unknown as GlobalWithTimers).setTimeout;
22
+ const clearTimer = (globalThis as unknown as GlobalWithTimers).clearTimeout;
23
+
24
+ // =============================================================================
25
+ // Streaming Procedure Caller Type
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Type for calling streaming client procedures.
30
+ * Returns an async iterable of results.
31
+ */
32
+ export interface StreamingProcedureCaller {
33
+ <TResult>(path: string[], input: unknown): AsyncIterable<TResult>;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Streaming Registry Implementation
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Create a splay-compatible streaming registry backed by client procedures.
42
+ *
43
+ * @param callStreaming - Client's streaming procedure call function
44
+ * @param options - Registry options
45
+ * @returns StreamingRegistry instance
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * import { stream } from "client";
50
+ * import { createStreamingRegistry } from "@procedures/splay";
51
+ *
52
+ * const registry = createStreamingRegistry(stream, {
53
+ * namespace: "live",
54
+ * bufferSize: 10,
55
+ * });
56
+ *
57
+ * // Use for live-updating components
58
+ * for await (const output of registry.get("ticker")!(ctx)) {
59
+ * render(output);
60
+ * }
61
+ * ```
62
+ */
63
+ export function createStreamingRegistry(
64
+ callStreaming: StreamingProcedureCaller,
65
+ options: StreamingRegistryOptions = {}
66
+ ): StreamingRegistry {
67
+ const { namespace, bufferSize = 16 } = options;
68
+
69
+ /**
70
+ * Build the procedure path for a component type.
71
+ */
72
+ function buildPath(type: string): string[] {
73
+ const basePath = ["components"];
74
+ if (namespace) {
75
+ basePath.push(namespace);
76
+ }
77
+ basePath.push(type);
78
+ return basePath;
79
+ }
80
+
81
+ /**
82
+ * Create a streaming renderer for a component type.
83
+ */
84
+ function createRenderer(type: string): StreamingComponentRenderer {
85
+ const procedurePath = buildPath(type);
86
+
87
+ return async function* (ctx: RenderContext): AsyncIterable<ComponentOutput> {
88
+ // Build the input for the component procedure
89
+ const input = {
90
+ data: ctx.data,
91
+ size: ctx.size,
92
+ path: ctx.path,
93
+ depth: ctx.depth,
94
+ };
95
+
96
+ // Stream from the procedure
97
+ const stream = callStreaming<ComponentOutput>(procedurePath, input);
98
+
99
+ // Yield each output with optional buffering
100
+ let buffer: ComponentOutput[] = [];
101
+
102
+ for await (const output of stream) {
103
+ if (bufferSize <= 1) {
104
+ // No buffering - yield immediately
105
+ yield output;
106
+ } else {
107
+ // Add to buffer
108
+ buffer.push(output);
109
+
110
+ // Yield when buffer is full
111
+ if (buffer.length >= bufferSize) {
112
+ // Yield the most recent output (discard older ones)
113
+ yield buffer[buffer.length - 1]!;
114
+ buffer = [];
115
+ }
116
+ }
117
+ }
118
+
119
+ // Yield any remaining buffered output
120
+ if (buffer.length > 0) {
121
+ yield buffer[buffer.length - 1]!;
122
+ }
123
+ };
124
+ }
125
+
126
+ // Cache of created renderers
127
+ const rendererCache = new Map<string, StreamingComponentRenderer>();
128
+
129
+ return {
130
+ /**
131
+ * Get a streaming renderer for a component type.
132
+ */
133
+ get(type: string): StreamingComponentRenderer | undefined {
134
+ let renderer = rendererCache.get(type);
135
+ if (renderer) {
136
+ return renderer;
137
+ }
138
+
139
+ renderer = createRenderer(type);
140
+ rendererCache.set(type, renderer);
141
+ return renderer;
142
+ },
143
+
144
+ /**
145
+ * Check if a streaming renderer exists.
146
+ */
147
+ has(_type: string): boolean {
148
+ return true; // Optimistic - actual check at call time
149
+ },
150
+ };
151
+ }
152
+
153
+ // =============================================================================
154
+ // Dual Registry (Sync + Streaming)
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Combined registry supporting both sync and streaming renderers.
159
+ */
160
+ export interface DualRegistry {
161
+ /** Sync registry for single-output components */
162
+ sync: {
163
+ get(type: string): ((ctx: RenderContext) => Promise<ComponentOutput>) | undefined;
164
+ has(type: string): boolean;
165
+ };
166
+
167
+ /** Streaming registry for multi-output components */
168
+ streaming: StreamingRegistry;
169
+
170
+ /**
171
+ * Render a component, automatically choosing sync or streaming.
172
+ * @param type - Component type
173
+ * @param ctx - Render context
174
+ * @param preferStreaming - Prefer streaming if available
175
+ */
176
+ render(
177
+ type: string,
178
+ ctx: RenderContext,
179
+ preferStreaming?: boolean
180
+ ): AsyncIterable<ComponentOutput>;
181
+ }
182
+
183
+ /**
184
+ * Create a dual registry supporting both sync and streaming.
185
+ *
186
+ * @param callSync - Client's sync procedure call function
187
+ * @param callStreaming - Client's streaming procedure call function
188
+ * @param isStreaming - Function to check if a component is streaming
189
+ * @param options - Registry options
190
+ * @returns DualRegistry instance
191
+ */
192
+ export function createDualRegistry(
193
+ callSync: <T>(path: string[], input: unknown) => Promise<T>,
194
+ callStreaming: StreamingProcedureCaller,
195
+ isStreaming: (type: string) => boolean,
196
+ options: StreamingRegistryOptions = {}
197
+ ): DualRegistry {
198
+ const { namespace } = options;
199
+
200
+ function buildPath(type: string): string[] {
201
+ const basePath = ["components"];
202
+ if (namespace) {
203
+ basePath.push(namespace);
204
+ }
205
+ basePath.push(type);
206
+ return basePath;
207
+ }
208
+
209
+ // Create sync renderer
210
+ function createSyncRenderer(type: string) {
211
+ const path = buildPath(type);
212
+ return async (ctx: RenderContext): Promise<ComponentOutput> => {
213
+ return callSync<ComponentOutput>(path, {
214
+ data: ctx.data,
215
+ size: ctx.size,
216
+ path: ctx.path,
217
+ depth: ctx.depth,
218
+ });
219
+ };
220
+ }
221
+
222
+ // Create streaming renderer
223
+ function createStreamingRenderer(type: string): StreamingComponentRenderer {
224
+ const path = buildPath(type);
225
+ return async function* (ctx: RenderContext) {
226
+ const input = {
227
+ data: ctx.data,
228
+ size: ctx.size,
229
+ path: ctx.path,
230
+ depth: ctx.depth,
231
+ };
232
+
233
+ for await (const output of callStreaming<ComponentOutput>(path, input)) {
234
+ yield output;
235
+ }
236
+ };
237
+ }
238
+
239
+ const syncCache = new Map<string, (ctx: RenderContext) => Promise<ComponentOutput>>();
240
+ const streamingCache = new Map<string, StreamingComponentRenderer>();
241
+
242
+ return {
243
+ sync: {
244
+ get(type: string) {
245
+ let renderer = syncCache.get(type);
246
+ if (!renderer) {
247
+ renderer = createSyncRenderer(type);
248
+ syncCache.set(type, renderer);
249
+ }
250
+ return renderer;
251
+ },
252
+ has(type: string) {
253
+ return !isStreaming(type);
254
+ },
255
+ },
256
+
257
+ streaming: {
258
+ get(type: string) {
259
+ let renderer = streamingCache.get(type);
260
+ if (!renderer) {
261
+ renderer = createStreamingRenderer(type);
262
+ streamingCache.set(type, renderer);
263
+ }
264
+ return renderer;
265
+ },
266
+ has(type: string) {
267
+ return isStreaming(type);
268
+ },
269
+ },
270
+
271
+ async *render(type: string, ctx: RenderContext, preferStreaming = false) {
272
+ const shouldStream = preferStreaming || isStreaming(type);
273
+
274
+ if (shouldStream) {
275
+ const renderer = this.streaming.get(type);
276
+ if (renderer) {
277
+ yield* renderer(ctx);
278
+ return;
279
+ }
280
+ }
281
+
282
+ // Fall back to sync
283
+ const syncRenderer = this.sync.get(type);
284
+ if (syncRenderer) {
285
+ yield await syncRenderer(ctx);
286
+ } else {
287
+ throw new Error(`No renderer found for component type: ${type}`);
288
+ }
289
+ },
290
+ };
291
+ }
292
+
293
+ // =============================================================================
294
+ // Stream Utilities
295
+ // =============================================================================
296
+
297
+ /** This function ends an iterator and does not wait: a source with a pending item ends after it. */
298
+ function endSource(iterator: AsyncIterator<unknown>): void {
299
+ try {
300
+ void Promise.resolve(iterator.return?.()).catch(() => undefined);
301
+ } catch {
302
+ // A source whose return() throws has nothing more to end
303
+ }
304
+ }
305
+
306
+ /**
307
+ * A queue with one writer and one reader: the reader waits on one wake-up, not on each source.
308
+ */
309
+ class Mailbox<T> {
310
+ private readonly items: T[] = [];
311
+ private wake: (() => void) | null = null;
312
+ private ended = false;
313
+ private failure: { error: unknown } | null = null;
314
+
315
+ push(item: T): void {
316
+ this.items.push(item);
317
+ this.notify();
318
+ }
319
+
320
+ end(failure?: { error: unknown }): void {
321
+ this.ended = true;
322
+ if (failure) this.failure = failure;
323
+ this.notify();
324
+ }
325
+
326
+ get isEnded(): boolean {
327
+ return this.ended;
328
+ }
329
+
330
+ private notify(): void {
331
+ const wake = this.wake;
332
+ this.wake = null;
333
+ wake?.();
334
+ }
335
+
336
+ /** The items, then the failure (if any) when the writer has ended. */
337
+ async *drain(): AsyncGenerator<T, void, undefined> {
338
+ for (;;) {
339
+ if (this.items.length > 0) {
340
+ yield this.items.shift()!;
341
+ continue;
342
+ }
343
+ if (this.ended) {
344
+ if (this.failure) throw this.failure.error;
345
+ return;
346
+ }
347
+ await new Promise<void>((resolve) => {
348
+ this.wake = resolve;
349
+ });
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Merge multiple component streams into one.
356
+ * Yields outputs from all streams as they arrive.
357
+ *
358
+ * Each source has at most one pending `next()`, and the reader waits on one shared wake-up.
359
+ * (Before, each round raced the pending promises of all sources: an idle source collected one
360
+ * reaction per item of the other sources, 81 MiB at 200k items. Deep dive DATA-12.) When the
361
+ * reader stops, or a source fails, the function ends the other sources.
362
+ */
363
+ export async function* mergeStreams(
364
+ ...streams: AsyncIterable<ComponentOutput>[]
365
+ ): AsyncIterable<ComponentOutput> {
366
+ type Settled =
367
+ | { index: number; result: IteratorResult<ComponentOutput> }
368
+ | { index: number; error: unknown };
369
+
370
+ const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
371
+ const active = new Set<number>();
372
+ const settled = new Mailbox<Settled>();
373
+
374
+ const pull = (index: number): void => {
375
+ iterators[index]!.next().then(
376
+ (result) => settled.push({ index, result }),
377
+ (error: unknown) => settled.push({ index, error }),
378
+ );
379
+ };
380
+
381
+ iterators.forEach((_, index) => {
382
+ active.add(index);
383
+ pull(index);
384
+ });
385
+
386
+ try {
387
+ if (active.size === 0) return;
388
+ for await (const item of settled.drain()) {
389
+ if ("error" in item) {
390
+ active.delete(item.index);
391
+ throw item.error;
392
+ }
393
+ if (item.result.done) {
394
+ active.delete(item.index);
395
+ if (active.size === 0) return;
396
+ continue;
397
+ }
398
+ yield item.result.value;
399
+ // The next item of this source, after the reader took this one
400
+ pull(item.index);
401
+ }
402
+ } finally {
403
+ for (const index of active) endSource(iterators[index]!);
404
+ }
405
+ }
406
+
407
+ /** The state of a timed stream: the source, the outputs, and the stop flag of the reader. */
408
+ interface TimedStream {
409
+ iterator: AsyncIterator<ComponentOutput>;
410
+ outputs: Mailbox<ComponentOutput>;
411
+ stopped: boolean;
412
+ }
413
+
414
+ /**
415
+ * Read a source in the background and give each item to `onItem`. `onEnd` runs when the source
416
+ * ends or fails, before the outputs end. When the reader stops, the source is not read further.
417
+ */
418
+ async function* timedStream(
419
+ stream: AsyncIterable<ComponentOutput>,
420
+ onItem: (output: ComponentOutput, state: TimedStream) => void,
421
+ onEnd: (state: TimedStream) => void,
422
+ onStop: () => void,
423
+ ): AsyncIterable<ComponentOutput> {
424
+ const state: TimedStream = {
425
+ iterator: stream[Symbol.asyncIterator](),
426
+ outputs: new Mailbox<ComponentOutput>(),
427
+ stopped: false,
428
+ };
429
+
430
+ void (async () => {
431
+ let failure: { error: unknown } | undefined;
432
+ try {
433
+ while (!state.stopped) {
434
+ const result = await state.iterator.next();
435
+ if (result.done || state.stopped) break;
436
+ onItem(result.value, state);
437
+ }
438
+ } catch (error) {
439
+ failure = { error };
440
+ } finally {
441
+ if (!state.stopped) onEnd(state);
442
+ state.outputs.end(failure);
443
+ }
444
+ })();
445
+
446
+ try {
447
+ yield* state.outputs.drain();
448
+ } finally {
449
+ if (!state.outputs.isEnded) {
450
+ // The reader stopped early: no more reads of the source (deep dive DATA-12)
451
+ state.stopped = true;
452
+ onStop();
453
+ endSource(state.iterator);
454
+ }
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Throttle a component stream to emit at most once per interval.
460
+ *
461
+ * The first item of an interval goes out at once. The last item that arrives during the
462
+ * interval goes out when the interval ends. (Before, it waited until the source ended: deep
463
+ * dive DATA-12.) When the source ends, a waiting item goes out at once.
464
+ */
465
+ export function throttleStream(
466
+ stream: AsyncIterable<ComponentOutput>,
467
+ intervalMs: number
468
+ ): AsyncIterable<ComponentOutput> {
469
+ let lastEmit = -Infinity;
470
+ let pending: { output: ComponentOutput } | null = null;
471
+ let timer: TimerId | null = null;
472
+
473
+ const stopTimer = (): void => {
474
+ if (timer !== null) {
475
+ clearTimer(timer);
476
+ timer = null;
477
+ }
478
+ };
479
+
480
+ return timedStream(
481
+ stream,
482
+ (output, state) => {
483
+ const now = Date.now();
484
+ if (timer === null && now - lastEmit >= intervalMs) {
485
+ lastEmit = now;
486
+ state.outputs.push(output);
487
+ return;
488
+ }
489
+ pending = { output };
490
+ if (timer === null) {
491
+ timer = setTimer(() => {
492
+ timer = null;
493
+ if (pending && !state.stopped) {
494
+ lastEmit = Date.now();
495
+ state.outputs.push(pending.output);
496
+ pending = null;
497
+ }
498
+ }, Math.max(0, lastEmit + intervalMs - now));
499
+ }
500
+ },
501
+ (state) => {
502
+ stopTimer();
503
+ if (pending) {
504
+ state.outputs.push(pending.output);
505
+ pending = null;
506
+ }
507
+ },
508
+ stopTimer,
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Debounce a component stream to emit only after a quiet period.
514
+ *
515
+ * When the stream ends, the last output goes out at once (BUGS-2026-07 H29). When the reader
516
+ * stops, the source is not read further (deep dive DATA-12).
517
+ */
518
+ export function debounceStream(
519
+ stream: AsyncIterable<ComponentOutput>,
520
+ waitMs: number
521
+ ): AsyncIterable<ComponentOutput> {
522
+ let latest: { output: ComponentOutput } | null = null;
523
+ let timer: TimerId | null = null;
524
+
525
+ const stopTimer = (): void => {
526
+ if (timer !== null) {
527
+ clearTimer(timer);
528
+ timer = null;
529
+ }
530
+ };
531
+
532
+ return timedStream(
533
+ stream,
534
+ (output, state) => {
535
+ latest = { output };
536
+ stopTimer();
537
+ timer = setTimer(() => {
538
+ timer = null;
539
+ if (latest && !state.stopped) {
540
+ state.outputs.push(latest.output);
541
+ latest = null;
542
+ }
543
+ }, waitMs);
544
+ },
545
+ (state) => {
546
+ stopTimer();
547
+ if (latest) {
548
+ state.outputs.push(latest.output);
549
+ latest = null;
550
+ }
551
+ },
552
+ stopTimer,
553
+ );
554
+ }
package/src/types.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Bridge Types
3
+ *
4
+ * Types for bridging splay's rendering model with client's procedure system.
5
+ */
6
+
7
+ import type { ComponentOutput, Size } from "./components/index.js";
8
+
9
+ // =============================================================================
10
+ // Render Context (splay-compatible)
11
+ // =============================================================================
12
+
13
+ /**
14
+ * Render context passed to component renderers.
15
+ * Compatible with splay's RenderContext interface.
16
+ */
17
+ export interface RenderContext<TData = unknown> {
18
+ /** Data to render */
19
+ data: TData;
20
+ /** Available size for rendering */
21
+ size: Size;
22
+ /** Path in the render tree */
23
+ path: string;
24
+ /** Depth in the render tree */
25
+ depth: number;
26
+ /** Render a child component */
27
+ render: (data: unknown, size: Size, path: string) => ComponentOutput | Promise<ComponentOutput>;
28
+ }
29
+
30
+ // =============================================================================
31
+ // Registry Interface (splay-compatible)
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Component renderer function.
36
+ */
37
+ export type ComponentRenderer<TOutput = ComponentOutput> = (
38
+ ctx: RenderContext
39
+ ) => TOutput | Promise<TOutput>;
40
+
41
+ /**
42
+ * Streaming component renderer function.
43
+ */
44
+ export type StreamingComponentRenderer<TOutput = ComponentOutput> = (
45
+ ctx: RenderContext
46
+ ) => AsyncIterable<TOutput>;
47
+
48
+ /**
49
+ * Registry interface compatible with splay.
50
+ */
51
+ export interface Registry<TOutput = ComponentOutput> {
52
+ /** Get a renderer for a component type */
53
+ get(type: string): ComponentRenderer<TOutput> | undefined;
54
+ /** Check if a renderer exists */
55
+ has(type: string): boolean;
56
+ /** Register a renderer (throws - use client procedures instead) */
57
+ register(type: string, renderer: ComponentRenderer<TOutput>): void;
58
+ }
59
+
60
+ /**
61
+ * Streaming registry interface.
62
+ */
63
+ export interface StreamingRegistry<TOutput = ComponentOutput> {
64
+ /** Get a streaming renderer for a component type */
65
+ get(type: string): StreamingComponentRenderer<TOutput> | undefined;
66
+ /** Check if a renderer exists */
67
+ has(type: string): boolean;
68
+ }
69
+
70
+ // =============================================================================
71
+ // Client Registry Options
72
+ // =============================================================================
73
+
74
+ /**
75
+ * Options for creating a client registry.
76
+ */
77
+ export interface ClientRegistryOptions {
78
+ /** Namespace prefix for component lookups (e.g., "ui" → "components.ui.{type}") */
79
+ namespace?: string;
80
+ /** Default size for components without explicit size */
81
+ defaultSize?: Size;
82
+ }
83
+
84
+ /**
85
+ * Options for creating a streaming registry.
86
+ */
87
+ export interface StreamingRegistryOptions extends ClientRegistryOptions {
88
+ /** Buffer size for streaming backpressure */
89
+ bufferSize?: number;
90
+ }
91
+
92
+ // =============================================================================
93
+ // Hydration Types
94
+ // =============================================================================
95
+
96
+ /**
97
+ * Component map for hydration.
98
+ * Maps component type names to framework-specific components.
99
+ */
100
+ export type ComponentMap<TComponent = unknown> = Record<string, TComponent>;
101
+
102
+ /**
103
+ * Hydration options.
104
+ */
105
+ export interface HydrateOptions<TComponent = unknown> {
106
+ /** Map of component types to framework components */
107
+ components: ComponentMap<TComponent>;
108
+ /** Fallback component for unknown types */
109
+ fallback?: TComponent;
110
+ /** Handler for null outputs */
111
+ onNull?: () => TComponent | null;
112
+ /** Handler for fragments */
113
+ onFragment?: (children: TComponent[]) => TComponent;
114
+ }