@roku-sdk/solid 0.0.0-stage → 2.19.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.
package/src/solid.ts ADDED
@@ -0,0 +1,899 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { logger } from "@roku-sdk/common";
5
+ import { createSDHelper } from "@roku-sdk/common/sd-helper";
6
+ import { stringInterner } from "@roku-sdk/common/string-interner";
7
+ import { registerPreCommitHook, transactionManager } from "@roku-sdk/common/transaction";
8
+ import {
9
+ AnNodeRef,
10
+ DomNode,
11
+ NodeFieldChangeEvent,
12
+ PropValue,
13
+ StructuredDataApi,
14
+ TmArrayRef,
15
+ TmNodeRef,
16
+ TmObjectRef,
17
+ TmStringRef,
18
+ TmValue,
19
+ TreeManipulationApi
20
+ } from "@roku-sdk/interfaces";
21
+ import type { Component, JSX } from "solid-js";
22
+ import { createMemo, splitProps, untrack } from "solid-js";
23
+ import { createRenderer } from "solid-js/universal";
24
+ export {
25
+ // Store — immutable
26
+ createStore,
27
+
28
+ // Store — mutable
29
+ createMutable,
30
+ modifyMutable,
31
+
32
+ // Store — modifiers
33
+ produce,
34
+ reconcile,
35
+ unwrap,
36
+
37
+ // Types
38
+ type ArrayFilterFn,
39
+ type DeepMutable,
40
+ type DeepReadonly,
41
+ type NotWrappable,
42
+ type Part,
43
+ type ReconcileOptions,
44
+ type SetStoreFunction,
45
+ type SolidStore,
46
+ type Store,
47
+ type StoreNode,
48
+ type StorePathRange,
49
+ type StoreSetter
50
+ } from "solid-js/store";
51
+
52
+ // Re-export solid-js APIs that are functional within the solid-js/universal renderer model.
53
+ // Platform-incompatible features (SSR hydration, lazy dynamic imports, concurrent mode,
54
+ // internal symbols) are explicitly excluded — see EXPORTS.md for the full rationale.
55
+ export {
56
+ // Reactive primitives
57
+ createSignal,
58
+ createEffect,
59
+ createMemo,
60
+ createResource,
61
+ createComputed,
62
+ batch,
63
+ untrack,
64
+
65
+ // Secondary primitives
66
+ createSelector,
67
+ createReaction,
68
+ createRenderEffect,
69
+
70
+ // Transitions (deferred reactive commits; microtask-batched without a scheduler)
71
+ startTransition,
72
+ useTransition,
73
+
74
+ // Dependency helpers
75
+ on,
76
+ from,
77
+ observable,
78
+
79
+ // Array helpers (low-level primitives underlying <For> and <Index>)
80
+ mapArray,
81
+ indexArray,
82
+
83
+ // Lifecycle
84
+ onMount,
85
+ onCleanup,
86
+
87
+ // Error handling
88
+ catchError,
89
+ resetErrorBoundaries,
90
+
91
+ // Context
92
+ createContext,
93
+ useContext,
94
+
95
+ // Owner management
96
+ getOwner,
97
+ runWithOwner,
98
+
99
+ // Dev-mode API (undefined in production builds)
100
+ DEV,
101
+
102
+ // Props utilities
103
+ mergeProps,
104
+ splitProps,
105
+
106
+ // Components and JSX
107
+ Show,
108
+ For,
109
+ Index,
110
+ children,
111
+ ErrorBoundary,
112
+ Suspense,
113
+ Switch,
114
+ Match,
115
+ createRoot,
116
+ createUniqueId
117
+ } from "solid-js";
118
+
119
+ export type {
120
+ Accessor,
121
+ AccessorArray,
122
+ ChildrenReturn,
123
+ Component,
124
+ ComponentProps,
125
+ Context,
126
+ ContextProviderComponent,
127
+ EffectFunction,
128
+ EffectOptions,
129
+ FlowComponent,
130
+ FlowProps,
131
+ InitializedResource,
132
+ InitializedResourceOptions,
133
+ InitializedResourceReturn,
134
+ JSX,
135
+ JSXElement,
136
+ MemoOptions,
137
+ MergeProps,
138
+ NoInfer,
139
+ ObservableObserver,
140
+ OnOptions,
141
+ Owner,
142
+ ParentComponent,
143
+ ParentProps,
144
+ Ref,
145
+ ResolvedChildren,
146
+ ResolvedJSXElement,
147
+ Resource,
148
+ ResourceActions,
149
+ ResourceFetcher,
150
+ ResourceFetcherInfo,
151
+ ResourceOptions,
152
+ ResourceReturn,
153
+ ResourceSource,
154
+ Setter,
155
+ Signal,
156
+ SignalOptions,
157
+ SplitProps,
158
+ Transition,
159
+ ValidComponent,
160
+ VoidComponent,
161
+ VoidProps
162
+ } from "solid-js";
163
+
164
+ import { createDomNode, isDomNode, setBlurHandler, setFocusHandler, setSetPropertyHandler } from "./dom-node";
165
+ import { commitFocus, focusNodeRemoved, initFocus, releaseFocus, requestFocus, resetFocus } from "./focus";
166
+ const solidLogger = logger.getLogger("fwk");
167
+
168
+ type TmTransaction = {
169
+ createElement(tag: string): DomNode;
170
+ setProperty(node: DomNode, name: string, value: TmValue, prev?: TmValue): void;
171
+ insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void;
172
+ removeNode(parent: DomNode, child: DomNode): void;
173
+ preCommit(): void;
174
+ };
175
+
176
+ /** Called after `tmTransaction.setProperty` applies a property (including event handlers). */
177
+ export type SetPropertyObserver = (node: DomNode, name: string, value: TmValue) => void;
178
+
179
+ const setPropertyObservers: SetPropertyObserver[] = [];
180
+
181
+ /**
182
+ * Register a callback invoked after every successful `tmTransaction.setProperty`.
183
+ * Observers are skipped entirely when none are registered (empty-list fast path).
184
+ *
185
+ * Intended for `__DEV__` diagnostics that need to see intrinsic JSX props (e.g. Effect
186
+ * gradient validation). Production builds that never register pay only an array-length check.
187
+ *
188
+ * Returns an unsubscribe function.
189
+ */
190
+ export const registerSetPropertyObserver = (observer: SetPropertyObserver): (() => void) => {
191
+ setPropertyObservers.push(observer);
192
+ return () => {
193
+ const index = setPropertyObservers.indexOf(observer);
194
+ if (index !== -1) {
195
+ setPropertyObservers.splice(index, 1);
196
+ }
197
+ };
198
+ };
199
+
200
+ const notifySetPropertyObservers = (node: DomNode, name: string, value: TmValue): void => {
201
+ if (setPropertyObservers.length === 0) return;
202
+ for (const observer of setPropertyObservers) {
203
+ // Observers are dev-only diagnostics; one that throws must not abort the
204
+ // transaction's setProperty call for every other observer and consumer.
205
+ try {
206
+ observer(node, name, value);
207
+ } catch (error) {
208
+ console.error("[solid] setProperty observer threw:", error);
209
+ }
210
+ }
211
+ };
212
+
213
+ export interface Renderer<NodeType> {
214
+ render(code: () => NodeType, node: NodeType): () => void;
215
+ effect<T>(fn: (prev?: T) => T, init?: T): void;
216
+ memo<T>(fn: () => T, equal: boolean): () => T;
217
+ createComponent<T>(Comp: (props: T) => NodeType, props: T): NodeType;
218
+ createElement(tag: string): NodeType;
219
+ createTextNode(value: string): NodeType;
220
+ insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void;
221
+ insert<T>(parent: NodeType, accessor: (() => T) | T, marker?: NodeType | null): NodeType;
222
+ spread<T>(node: NodeType, accessor: (() => T) | T, skipChildren?: boolean): void;
223
+ setProp<T>(node: NodeType, name: string, value: T, prev?: T): T;
224
+ use<A, T>(fn: (element: NodeType, arg: A) => T, element: NodeType, arg: A): T;
225
+ }
226
+
227
+ type EventCallback = (...args: unknown[]) => void;
228
+ export interface SolidRenderer {
229
+ renderer: Renderer<DomNode>;
230
+ tmFieldChangeHandlerApi: NodeFieldChangeEvent;
231
+ }
232
+ export interface MakeRenderer {
233
+ createRenderer: () => Renderer<DomNode>;
234
+ destroyRenderer: () => void;
235
+ tmFieldChangeHandlerApi: NodeFieldChangeEvent;
236
+ }
237
+
238
+ type DynamicFnProps<T extends object> = {
239
+ component: Component<T> | null | undefined;
240
+ } & Omit<T, "component">;
241
+
242
+ type DynamicIntrinsicProps<K extends keyof JSX.IntrinsicElements> = {
243
+ component: K;
244
+ } & JSX.IntrinsicElements[K];
245
+
246
+ export function Dynamic<T extends object>(props: DynamicFnProps<T>): JSX.Element;
247
+ export function Dynamic<K extends keyof JSX.IntrinsicElements>(props: DynamicIntrinsicProps<K>): JSX.Element;
248
+ export function Dynamic(props: { component: unknown }): JSX.Element {
249
+ const [local, others] = splitProps(props, ["component"] as const);
250
+
251
+ return createMemo(() => {
252
+ const component = local.component;
253
+
254
+ if (typeof component === "function") {
255
+ return untrack(() =>
256
+ createComponent(component as unknown as (componentProps: typeof others) => DomNode, others)
257
+ );
258
+ }
259
+
260
+ if (typeof component === "string") {
261
+ const node = createElement(component);
262
+ untrack(() => spread(node, others, false));
263
+ return node;
264
+ }
265
+
266
+ return null;
267
+ }) as unknown as JSX.Element;
268
+ }
269
+
270
+ let tmApi: TreeManipulationApi;
271
+ let createFullArray: (array: unknown[]) => TmArrayRef;
272
+ let createFullObject: (props: object) => TmObjectRef;
273
+ let sdCommit: () => void;
274
+ const observerCallbacks = new WeakMap<TmNodeRef, WeakMap<TmStringRef, EventCallback>>();
275
+
276
+ // Tree calls identify their target by node refs; anchor node ref is ignored.
277
+ const NO_ANCHOR: AnNodeRef = Object.freeze({});
278
+
279
+ // Local to the transaction.
280
+ const nodesToRemove = new Set<DomNode>();
281
+
282
+ // PanelSet orders children by append sequence and ignores the position passed to
283
+ // insertChild, so its children are attached natively in preCommit, in final order.
284
+ // Entries are never evicted early: a node that was removed or re-parented no longer
285
+ // has the pending parent as `parent`, and flushPendingAppends skips it.
286
+ // The tag is compared as a literal because the check runs on every insert.
287
+ const pendingAppendParents = new Set<DomNode>();
288
+ const pendingAppendChildren = new Set<DomNode>();
289
+ // Mirrors "pendingAppendParents is non-empty" without a Map/Set size getter on hot paths.
290
+ let appendsPending = false;
291
+
292
+ /** First child at or after `start` that is already attached natively. */
293
+ function nextNativeSibling(children: DomNode[], start: number): DomNode | undefined {
294
+ for (let i = start; i < children.length; i++) {
295
+ const child = children[i];
296
+ if (child && !pendingAppendChildren.has(child)) {
297
+ return child;
298
+ }
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ function warnUnorderedAppendOnlyInsert(parent: DomNode, node: DomNode, anchor: DomNode): void {
304
+ solidLogger.warn(
305
+ () =>
306
+ `${parent.t} only appends children: ${node.t} placed before ${anchor.t} may not keep that position in SceneGraph`
307
+ );
308
+ }
309
+
310
+ function insertIntoAppendOnlyParent(parent: DomNode, node: DomNode, anchor: DomNode | undefined): void {
311
+ const children = (parent.c = parent.c || []);
312
+ const nodeIdx = children.indexOf(node);
313
+ if (nodeIdx !== -1) {
314
+ children.splice(nodeIdx, 1);
315
+ }
316
+ node.parent = parent;
317
+ let anchorIdx = anchor ? children.indexOf(anchor) : -1;
318
+ if (anchorIdx === -1) {
319
+ anchorIdx = children.length;
320
+ }
321
+ children.splice(anchorIdx, 0, node);
322
+
323
+ if (nodeIdx === -1 || (appendsPending && pendingAppendChildren.has(node))) {
324
+ appendsPending = true;
325
+ pendingAppendChildren.add(node);
326
+ pendingAppendParents.add(parent);
327
+ solidLogger.trace(() => `insertNode: ${node.t} deferred to commit in parent: ${parent.t}`);
328
+ return;
329
+ }
330
+
331
+ // Moving an attached child: pending siblings are not in SceneGraph yet, so anchor on the next attached one.
332
+ const next = nextNativeSibling(children, anchorIdx + 1);
333
+ if (next) {
334
+ warnUnorderedAppendOnlyInsert(parent, node, next);
335
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, node.r, next.r);
336
+ } else {
337
+ tmApi.appendNode(NO_ANCHOR, parent.r, node.r);
338
+ }
339
+ solidLogger.trace(() => `insertNode: moved ${node.t} in parent: ${parent.t}`);
340
+ }
341
+
342
+ function resetPendingAppends(): void {
343
+ pendingAppendParents.clear();
344
+ pendingAppendChildren.clear();
345
+ appendsPending = false;
346
+ }
347
+
348
+ function flushPendingAppends(): void {
349
+ pendingAppendParents.forEach(parent => {
350
+ const children = parent.c ?? [];
351
+ for (let i = 0; i < children.length; i++) {
352
+ const child = children[i];
353
+ if (!child || child.parent !== parent || !pendingAppendChildren.delete(child)) {
354
+ continue;
355
+ }
356
+ const next = nextNativeSibling(children, i + 1);
357
+ if (next) {
358
+ warnUnorderedAppendOnlyInsert(parent, child, next);
359
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, child.r, next.r);
360
+ } else {
361
+ tmApi.appendNode(NO_ANCHOR, parent.r, child.r);
362
+ }
363
+ }
364
+ });
365
+ resetPendingAppends();
366
+ }
367
+
368
+ function getParent(node: DomNode): DomNode | undefined {
369
+ return node.parent;
370
+ }
371
+
372
+ // TODO: Time this operation
373
+ const removeNodeRecursively = (node: DomNode) => {
374
+ const attachParent = node.parent;
375
+ const stack = [node];
376
+ while (stack.length > 0) {
377
+ const currentNode = stack.pop();
378
+ if (!currentNode) {
379
+ continue;
380
+ }
381
+ if (currentNode.c) {
382
+ stack.push(...currentNode.c);
383
+ }
384
+
385
+ focusNodeRemoved(currentNode, attachParent);
386
+
387
+ if (currentNode.r) {
388
+ observerCallbacks.delete(currentNode.r);
389
+ // NOTE: We can call unregisterFieldHandler here.
390
+ // We are not doing it because removeNode must anyway unregister all the field handlers on native side
391
+
392
+ // clear parent pointer
393
+ currentNode.parent = undefined;
394
+ }
395
+ }
396
+ };
397
+
398
+ // Invoked only from unit tests
399
+ export function cleanUp(): void {
400
+ nodesToRemove.clear();
401
+ resetPendingAppends();
402
+ resetFocus();
403
+ }
404
+
405
+ // Cache for event handler property name classification
406
+ const isEventHandlerCache = new Map<string, boolean>();
407
+ const eventNameTransformCache = new Map<string, string>();
408
+
409
+ function isEventHandler(key: string): boolean {
410
+ let result = isEventHandlerCache.get(key);
411
+ if (result === undefined) {
412
+ // Pattern: on[A-Z] - onStatus, onFocus, etc.
413
+ // Use charAt so noUncheckedIndexedAccess does not widen key[i] to string | undefined.
414
+ result =
415
+ key.length > 2 &&
416
+ key.charAt(0) === "o" &&
417
+ key.charAt(1) === "n" &&
418
+ key.charAt(2) >= "A" &&
419
+ key.charAt(2) <= "Z";
420
+ isEventHandlerCache.set(key, result);
421
+ }
422
+ return result;
423
+ }
424
+
425
+ function transformEventName(eventName: string): string {
426
+ let transformed = eventNameTransformCache.get(eventName);
427
+ if (transformed === undefined) {
428
+ // onFocus -> focus, onLoadStatus -> loadStatus
429
+ transformed = eventName.slice(2).charAt(0).toLowerCase() + eventName.slice(3);
430
+ eventNameTransformCache.set(eventName, transformed);
431
+ }
432
+ return transformed;
433
+ }
434
+
435
+ const tagAliases = new Map<string, () => string>();
436
+
437
+ /**
438
+ * Route a JSX tag to a different SceneGraph node type, decided when each node is created.
439
+ * `resolve` runs on every creation of `tag`, so it must be cheap; it may throw to refuse
440
+ * creation outright. A later registration for the same tag replaces the earlier one.
441
+ *
442
+ * The created node's `t` is the resolved type, not `tag`: a check such as `node.t === "tween"`
443
+ * misses a node created as its shim, so match every type the alias can resolve to.
444
+ *
445
+ * @param tag - The JSX tag as written in source, e.g. `"tween"`.
446
+ * @param resolve - Returns the SceneGraph node type to create for `tag`.
447
+ */
448
+ export const registerTagAlias = (tag: string, resolve: () => string): void => {
449
+ tagAliases.set(tag, resolve);
450
+ };
451
+
452
+ /** Whether {@link registerTagAlias} has been called for `tag`. */
453
+ export const hasTagAlias = (tag: string): boolean => tagAliases.has(tag);
454
+
455
+ export const tmTransaction: TmTransaction = {
456
+ createElement: (tag: string): DomNode => {
457
+ const sgType = tagAliases.size === 0 ? tag : (tagAliases.get(tag)?.() ?? tag);
458
+ const tagRef: TmStringRef | undefined = stringInterner.internString(sgType);
459
+ if (tagRef === undefined) {
460
+ throw new Error(`Tag not interned: ${sgType}`);
461
+ }
462
+ const nodeRef: TmNodeRef = tmApi.createNode(NO_ANCHOR, tagRef);
463
+ const element: DomNode = createDomNode(sgType, nodeRef);
464
+ solidLogger.trace(() => `createElement: ${tag === sgType ? tag : `${tag} as ${sgType}`}`);
465
+ return element;
466
+ },
467
+ setProperty: (node: DomNode, name: string, value: TmValue, prev?: TmValue): void => {
468
+ const nodeRef = node.r;
469
+ let eventProp = false;
470
+ if (isEventHandler(name)) {
471
+ name = transformEventName(name);
472
+ eventProp = true;
473
+ }
474
+ const propRef: TmStringRef | undefined = stringInterner.internString(name);
475
+ if (propRef === undefined) {
476
+ throw new Error(`Property not interned: ${name}`);
477
+ }
478
+
479
+ solidLogger.trace(() => `setProperty: node: ${node.t}, name: ${name}, value: ${value}, prev: ${prev}`);
480
+ if (eventProp) {
481
+ if (typeof prev === "function") {
482
+ tmApi.unregisterFieldHandler(NO_ANCHOR, node.r, propRef);
483
+ }
484
+
485
+ if (typeof value === "function") {
486
+ tmApi.registerFieldChangeHandler(NO_ANCHOR, node.r, propRef);
487
+ }
488
+
489
+ let callbacks = observerCallbacks.get(nodeRef);
490
+
491
+ if (!callbacks) {
492
+ callbacks = new WeakMap<TmStringRef, EventCallback>();
493
+ observerCallbacks.set(nodeRef, callbacks);
494
+ }
495
+ callbacks.set(propRef, value as () => void);
496
+ notifySetPropertyObservers(node, name, value);
497
+ return;
498
+ } else {
499
+ switch (typeof value) {
500
+ case "boolean":
501
+ if (name === "gainFocus") {
502
+ if (value) {
503
+ requestFocus(node);
504
+ } else {
505
+ releaseFocus(node);
506
+ }
507
+ break;
508
+ }
509
+ tmApi.setNodePropBool(NO_ANCHOR, nodeRef, propRef, value);
510
+ break;
511
+ case "number":
512
+ if (Number.isInteger(value)) {
513
+ tmApi.setNodePropInt(NO_ANCHOR, nodeRef, propRef, value);
514
+ } else {
515
+ tmApi.setNodePropFloat(NO_ANCHOR, nodeRef, propRef, value);
516
+ }
517
+ break;
518
+ case "string":
519
+ tmApi.setNodePropString(NO_ANCHOR, nodeRef, propRef, value);
520
+ break;
521
+ case "object":
522
+ if (value === null) {
523
+ tmApi.setNodePropNull(NO_ANCHOR, nodeRef, propRef);
524
+ } else if (Array.isArray(value)) {
525
+ const arrayRef = createFullArray(value);
526
+ tmApi.setNodePropArray(NO_ANCHOR, nodeRef, propRef, arrayRef);
527
+ } else if (isDomNode(value as DomNode)) {
528
+ tmApi.setNodePropNodeRef(NO_ANCHOR, nodeRef, propRef, (value as DomNode).r);
529
+ } else {
530
+ const objectRef: TmObjectRef = createFullObject(value);
531
+ tmApi.setNodePropObject(NO_ANCHOR, nodeRef, propRef, objectRef);
532
+ }
533
+ break;
534
+ }
535
+ }
536
+
537
+ if (typeof prev === "object" && isDomNode(prev as DomNode)) {
538
+ nodesToRemove.add(prev as DomNode);
539
+ }
540
+ notifySetPropertyObservers(node, name, value);
541
+ },
542
+ insertNode: (parent: DomNode, node: DomNode, anchor?: DomNode): void => {
543
+ // Do nothing if the node is anchor itself
544
+ if (node === anchor) {
545
+ return;
546
+ }
547
+ if (parent.t === "panelSet") {
548
+ insertIntoAppendOnlyParent(parent, node, anchor);
549
+ nodesToRemove.delete(node);
550
+ return;
551
+ }
552
+ // Remove the node if its already in the parent
553
+ if (parent.c) {
554
+ const nodeIdx = parent.c.indexOf(node);
555
+ if (nodeIdx !== -1) {
556
+ parent.c.splice(nodeIdx, 1);
557
+ }
558
+ }
559
+
560
+ // Plain assignment — `parent` slot is pre-defined as non-enumerable
561
+ // in createDomNode, so this hits the fast IC path.
562
+ node.parent = parent;
563
+ if (anchor && parent.c) {
564
+ const anchorIdx = parent.c.indexOf(anchor);
565
+ if (anchorIdx !== -1) {
566
+ parent.c.splice(anchorIdx, 0, node);
567
+ }
568
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, node.r, anchor.r);
569
+ solidLogger.trace(() => `insertNode: ${node.t} before anchor: ${anchor.t} in parent: ${parent.t}`);
570
+ } else {
571
+ parent.c = parent.c || [];
572
+ parent.c.push(node);
573
+ tmApi.appendNode(NO_ANCHOR, parent.r, node.r);
574
+ solidLogger.trace(() => `appendNode: ${node.t} to parent: ${parent.t}`);
575
+ }
576
+
577
+ if (nodesToRemove.has(node)) {
578
+ nodesToRemove.delete(node);
579
+ }
580
+ },
581
+ removeNode: (parent: DomNode, child: DomNode): void => {
582
+ if (parent.c) {
583
+ const childIdx = parent.c.indexOf(child);
584
+ if (childIdx !== -1) {
585
+ parent.c.splice(childIdx, 1);
586
+ }
587
+ }
588
+
589
+ nodesToRemove.add(child);
590
+ },
591
+ // Note: Precomit is used to remove nodes & set focus.
592
+ // Node removal is done later as solid.js can remove & attach nodes in the same transaction.
593
+ // Focus is set at the end of the transaction as focus changes are lost of setFocus is called in middle of the transaction.
594
+ preCommit: () => {
595
+ nodesToRemove.forEach(node => {
596
+ const parent = getParent(node);
597
+ // NOTE: Parent might be missing if the node is assigned as as prop
598
+ if (parent && !(appendsPending && parent.t === "panelSet" && pendingAppendChildren.has(node))) {
599
+ tmApi.removeNode(NO_ANCHOR, parent.r, node.r);
600
+ solidLogger.trace(() => `removeNode: ${node.t} from parent: ${parent.t}`);
601
+ }
602
+ removeNodeRecursively(node);
603
+ });
604
+ if (appendsPending) {
605
+ flushPendingAppends();
606
+ }
607
+
608
+ commitFocus();
609
+
610
+ nodesToRemove.clear();
611
+ sdCommit();
612
+ }
613
+ };
614
+
615
+ registerPreCommitHook(tmTransaction.preCommit);
616
+ setSetPropertyHandler((node, name, value) => {
617
+ transactionManager.startTransaction();
618
+ tmTransaction.setProperty(node, name, value);
619
+ });
620
+ setFocusHandler((focusNode: DomNode) => {
621
+ transactionManager.startTransaction();
622
+ requestFocus(focusNode);
623
+ });
624
+ setBlurHandler((blurNode: DomNode) => {
625
+ transactionManager.startTransaction();
626
+ releaseFocus(blurNode);
627
+ solidLogger.trace(() => `setBlur: ${blurNode.t}`);
628
+ });
629
+
630
+ function createSolidRenderer(): Renderer<DomNode> {
631
+ return createRenderer<DomNode>({
632
+ createElement(tag: string): DomNode {
633
+ transactionManager.startTransaction();
634
+ return tmTransaction.createElement(tag);
635
+ },
636
+ createTextNode(value: string): DomNode {
637
+ if (value !== "") {
638
+ solidLogger.warn(
639
+ () => `Text content "${value}" is not rendered on Roku SceneGraph. Use <label> for text display.`
640
+ );
641
+ }
642
+ transactionManager.startTransaction();
643
+ return tmTransaction.createElement("group");
644
+ },
645
+ replaceText(textNode: DomNode, value: string): void {
646
+ solidLogger.trace(() => `replaceText: textNode: ${textNode} value: ${value}`);
647
+ return;
648
+ },
649
+ isTextNode(node: DomNode): boolean {
650
+ solidLogger.trace(() => `isTextNode node: ${JSON.stringify(node)}`);
651
+ return false;
652
+ },
653
+ setProperty<T>(node: DomNode, name: string, value: T, prev?: T): void {
654
+ transactionManager.startTransaction();
655
+ tmTransaction.setProperty(node, name, value as TmValue, prev as TmValue);
656
+ },
657
+ insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void {
658
+ transactionManager.startTransaction();
659
+ tmTransaction.insertNode(parent, node, anchor);
660
+ },
661
+ removeNode(parent: DomNode, child: DomNode): void {
662
+ transactionManager.startTransaction();
663
+ tmTransaction.removeNode(parent, child);
664
+ },
665
+ getParentNode(node: DomNode): DomNode | undefined {
666
+ return getParent(node);
667
+ },
668
+ getFirstChild(node: DomNode): DomNode | undefined {
669
+ return node?.c?.[0] ?? undefined;
670
+ },
671
+ getNextSibling(node: DomNode): DomNode | undefined {
672
+ const parent = getParent(node);
673
+ if (parent && parent.c) {
674
+ return parent.c[parent.c.indexOf(node) + 1] ?? undefined;
675
+ }
676
+ return undefined;
677
+ }
678
+ });
679
+ }
680
+
681
+ function destroySolidRenderer(): void {
682
+ // no-op
683
+ }
684
+
685
+ function makeRendererFactory(
686
+ type: string,
687
+ api: TreeManipulationApi,
688
+ incomingSDApi: StructuredDataApi,
689
+ outgoingSDApi: StructuredDataApi
690
+ ): MakeRenderer {
691
+ tmApi = api;
692
+ initFocus(api, solidLogger, NO_ANCHOR);
693
+ ({ createFullArray, createFullObject, commit: sdCommit } = createSDHelper(outgoingSDApi));
694
+
695
+ const fieldChangeHandler = (() => {
696
+ const invokeCallback = (_anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: PropValue) => {
697
+ const callbacks = observerCallbacks.get(nodeRef);
698
+ if (callbacks) {
699
+ const callback = callbacks.get(fieldRef);
700
+ if (callback && typeof callback === "function") {
701
+ callback(value);
702
+ }
703
+ }
704
+ };
705
+
706
+ return {
707
+ onNodeNullFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef) => {
708
+ invokeCallback(anRef, nodeRef, fieldRef, null);
709
+ },
710
+ onNodeBoolFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: boolean) => {
711
+ invokeCallback(anRef, nodeRef, fieldRef, value);
712
+ },
713
+ onNodeIntFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: number) => {
714
+ invokeCallback(anRef, nodeRef, fieldRef, value);
715
+ },
716
+ onNodeFloatFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: number) => {
717
+ invokeCallback(anRef, nodeRef, fieldRef, value);
718
+ },
719
+ onNodeStringFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: string) => {
720
+ invokeCallback(anRef, nodeRef, fieldRef, value);
721
+ },
722
+ onNodeObjectFieldChange: (
723
+ anRef: AnNodeRef,
724
+ nodeRef: TmNodeRef,
725
+ fieldRef: TmStringRef,
726
+ valueRef: TmObjectRef
727
+ ) => {
728
+ const data = incomingSDApi.getObject(valueRef);
729
+ if (!data) {
730
+ solidLogger.error(() => `Failed to get object from ref`);
731
+ return;
732
+ }
733
+ invokeCallback(anRef, nodeRef, fieldRef, data as PropValue);
734
+ },
735
+ onNodeArrayFieldChange: (
736
+ anRef: AnNodeRef,
737
+ nodeRef: TmNodeRef,
738
+ fieldRef: TmStringRef,
739
+ valueRef: TmArrayRef
740
+ ) => {
741
+ const data = incomingSDApi.getArray(valueRef);
742
+ if (!data) {
743
+ solidLogger.error(() => `Failed to get array from ref`);
744
+ return;
745
+ }
746
+ invokeCallback(anRef, nodeRef, fieldRef, data as PropValue);
747
+ }
748
+ };
749
+ })();
750
+
751
+ return {
752
+ createRenderer: createSolidRenderer,
753
+ destroyRenderer: destroySolidRenderer,
754
+ tmFieldChangeHandlerApi: fieldChangeHandler
755
+ };
756
+ }
757
+
758
+ function throwUninitializedRendererHook(hookName: string): never {
759
+ throw new Error(
760
+ `@roku-sdk/solid internal hook "${hookName}" was called before renderer initialization. ` +
761
+ `Ensure makeSolidRenderer() runs via @roku-sdk/rsg-ts setUp() before rendering JSX.`
762
+ );
763
+ }
764
+
765
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
766
+ function uninitializedHook(name: string): (...args: any[]) => never {
767
+ return () => throwUninitializedRendererHook(name);
768
+ }
769
+
770
+ export let effect: <T>(fn: (prev?: T) => T, init?: T) => void,
771
+ memo: <T>(fn: () => T, equal: boolean) => () => T,
772
+ createComponent: <T>(Comp: (props: T) => DomNode, props: T) => DomNode,
773
+ createElement: (tag: string) => DomNode,
774
+ createTextNode: (value: string) => DomNode,
775
+ insertNode: (parent: DomNode, node: DomNode, anchor?: DomNode) => void,
776
+ insert: <T>(parent: DomNode, accessor: (() => T) | T, marker?: DomNode | null) => DomNode,
777
+ spread: <T>(node: DomNode, accessor: (() => T) | T, skipChildren?: boolean) => void,
778
+ setProp: <T>(node: DomNode, name: string, value: T, prev?: T) => T,
779
+ use: <A, T>(fn: (element: DomNode, arg: A) => T, element: DomNode, arg: A) => T;
780
+
781
+ // Guard all hooks against accidental use before makeSolidRenderer() wires them.
782
+ effect = uninitializedHook("effect") as typeof effect;
783
+ memo = uninitializedHook("memo") as typeof memo;
784
+ createComponent = uninitializedHook("createComponent") as typeof createComponent;
785
+ createElement = uninitializedHook("createElement") as typeof createElement;
786
+ createTextNode = uninitializedHook("createTextNode") as typeof createTextNode;
787
+ insertNode = uninitializedHook("insertNode") as typeof insertNode;
788
+ insert = uninitializedHook("insert") as typeof insert;
789
+ spread = uninitializedHook("spread") as typeof spread;
790
+ setProp = uninitializedHook("setProp") as typeof setProp;
791
+ use = uninitializedHook("use") as typeof use;
792
+
793
+ export function makeSolidRenderer(
794
+ api: TreeManipulationApi,
795
+ incomingSDApi: StructuredDataApi,
796
+ outgoingSDApi: StructuredDataApi
797
+ ): SolidRenderer {
798
+ // No reason to destroy the renderer. Dispose function need to be called to clean up the resources
799
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
800
+ const { createRenderer, destroyRenderer, tmFieldChangeHandlerApi } = makeRendererFactory(
801
+ "solid",
802
+ api,
803
+ incomingSDApi,
804
+ outgoingSDApi
805
+ );
806
+ const renderer = createRenderer();
807
+ ({ effect, memo, createComponent, createElement, createTextNode, insertNode, insert, spread, setProp, use } =
808
+ renderer);
809
+
810
+ // Wrap spread to ensure event handlers are registered before regular props
811
+ const originalSpread = spread;
812
+ spread = <T>(node: DomNode, accessor: (() => T) | T, skipChildren?: boolean) => {
813
+ // Helper to create reordered proxy from props object
814
+ const createReorderedProxy = (propsObj: Record<string, unknown>) => {
815
+ const keys = Object.keys(propsObj);
816
+
817
+ // If empty object or only one property, no need to reorder
818
+ if (keys.length <= 1) {
819
+ return propsObj;
820
+ }
821
+
822
+ const eventHandlers: string[] = [];
823
+ const regularProps: string[] = [];
824
+ let needsReorder = false;
825
+ let seenRegularProp = false;
826
+
827
+ // Partition using cached results and check if already ordered
828
+ for (let i = 0; i < keys.length; i++) {
829
+ const key = keys[i];
830
+ if (key === undefined) {
831
+ continue;
832
+ }
833
+ if (isEventHandler(key)) {
834
+ // If we've already seen a regular prop, order is wrong
835
+ if (seenRegularProp) {
836
+ needsReorder = true;
837
+ }
838
+ eventHandlers.push(key);
839
+ } else {
840
+ seenRegularProp = true;
841
+ regularProps.push(key);
842
+ }
843
+ }
844
+
845
+ // If all same type, no need to reorder
846
+ if (eventHandlers.length === 0 || regularProps.length === 0) {
847
+ return propsObj;
848
+ }
849
+
850
+ // If already in correct order, return original object
851
+ if (!needsReorder) {
852
+ return propsObj;
853
+ }
854
+
855
+ // Create ordered keys array: event handlers first, then regular props
856
+ const orderedKeys = [...eventHandlers, ...regularProps];
857
+
858
+ // Create a Proxy that preserves reactive getters but controls key iteration order
859
+ return new Proxy(propsObj, {
860
+ ownKeys: () => orderedKeys,
861
+ get: (target, prop) => target[prop as keyof typeof target],
862
+ getOwnPropertyDescriptor: (target, prop) => {
863
+ // Return original descriptor to preserve references and avoid breaking equality checks
864
+ if (orderedKeys.includes(prop as string)) {
865
+ return Object.getOwnPropertyDescriptor(target, prop);
866
+ }
867
+ return undefined;
868
+ }
869
+ });
870
+ };
871
+
872
+ // Handle function accessors - defer evaluation to preserve reactive tracking
873
+ if (typeof accessor === "function") {
874
+ const wrappedAccessor = () => {
875
+ const props = (accessor as () => T)();
876
+ if (props && typeof props === "object" && !Array.isArray(props)) {
877
+ return createReorderedProxy(props as Record<string, unknown>);
878
+ }
879
+ return props;
880
+ };
881
+ originalSpread(node, wrappedAccessor, skipChildren);
882
+ } else {
883
+ // Handle static objects
884
+ if (accessor && typeof accessor === "object" && !Array.isArray(accessor)) {
885
+ const reordered = createReorderedProxy(accessor as Record<string, unknown>);
886
+ originalSpread(node, reordered, skipChildren);
887
+ } else {
888
+ originalSpread(node, accessor, skipChildren);
889
+ }
890
+ }
891
+ };
892
+
893
+ // Update the renderer object with the wrapped spread
894
+ renderer.spread = spread;
895
+ return {
896
+ renderer,
897
+ tmFieldChangeHandlerApi
898
+ };
899
+ }