@roku-sdk/solid 0.0.0-stage → 2.18.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,860 @@
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
+ export interface Renderer<NodeType> {
177
+ render(code: () => NodeType, node: NodeType): () => void;
178
+ effect<T>(fn: (prev?: T) => T, init?: T): void;
179
+ memo<T>(fn: () => T, equal: boolean): () => T;
180
+ createComponent<T>(Comp: (props: T) => NodeType, props: T): NodeType;
181
+ createElement(tag: string): NodeType;
182
+ createTextNode(value: string): NodeType;
183
+ insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void;
184
+ insert<T>(parent: NodeType, accessor: (() => T) | T, marker?: NodeType | null): NodeType;
185
+ spread<T>(node: NodeType, accessor: (() => T) | T, skipChildren?: boolean): void;
186
+ setProp<T>(node: NodeType, name: string, value: T, prev?: T): T;
187
+ use<A, T>(fn: (element: NodeType, arg: A) => T, element: NodeType, arg: A): T;
188
+ }
189
+
190
+ type EventCallback = (...args: unknown[]) => void;
191
+ export interface SolidRenderer {
192
+ renderer: Renderer<DomNode>;
193
+ tmFieldChangeHandlerApi: NodeFieldChangeEvent;
194
+ }
195
+ export interface MakeRenderer {
196
+ createRenderer: () => Renderer<DomNode>;
197
+ destroyRenderer: () => void;
198
+ tmFieldChangeHandlerApi: NodeFieldChangeEvent;
199
+ }
200
+
201
+ type DynamicFnProps<T extends object> = {
202
+ component: Component<T> | null | undefined;
203
+ } & Omit<T, "component">;
204
+
205
+ type DynamicIntrinsicProps<K extends keyof JSX.IntrinsicElements> = {
206
+ component: K;
207
+ } & JSX.IntrinsicElements[K];
208
+
209
+ export function Dynamic<T extends object>(props: DynamicFnProps<T>): JSX.Element;
210
+ export function Dynamic<K extends keyof JSX.IntrinsicElements>(props: DynamicIntrinsicProps<K>): JSX.Element;
211
+ export function Dynamic(props: { component: unknown }): JSX.Element {
212
+ const [local, others] = splitProps(props, ["component"] as const);
213
+
214
+ return createMemo(() => {
215
+ const component = local.component;
216
+
217
+ if (typeof component === "function") {
218
+ return untrack(() =>
219
+ createComponent(component as unknown as (componentProps: typeof others) => DomNode, others)
220
+ );
221
+ }
222
+
223
+ if (typeof component === "string") {
224
+ const node = createElement(component);
225
+ untrack(() => spread(node, others, false));
226
+ return node;
227
+ }
228
+
229
+ return null;
230
+ }) as unknown as JSX.Element;
231
+ }
232
+
233
+ let tmApi: TreeManipulationApi;
234
+ let createFullArray: (array: unknown[]) => TmArrayRef;
235
+ let createFullObject: (props: object) => TmObjectRef;
236
+ let sdCommit: () => void;
237
+ const observerCallbacks = new WeakMap<TmNodeRef, WeakMap<TmStringRef, EventCallback>>();
238
+
239
+ // Tree calls identify their target by node refs; anchor node ref is ignored.
240
+ const NO_ANCHOR: AnNodeRef = Object.freeze({});
241
+
242
+ // Local to the transaction.
243
+ const nodesToRemove = new Set<DomNode>();
244
+
245
+ // PanelSet orders children by append sequence and ignores the position passed to
246
+ // insertChild, so its children are attached natively in preCommit, in final order.
247
+ // Entries are never evicted early: a node that was removed or re-parented no longer
248
+ // has the pending parent as `parent`, and flushPendingAppends skips it.
249
+ // The tag is compared as a literal because the check runs on every insert.
250
+ const pendingAppendParents = new Set<DomNode>();
251
+ const pendingAppendChildren = new Set<DomNode>();
252
+ // Mirrors "pendingAppendParents is non-empty" without a Map/Set size getter on hot paths.
253
+ let appendsPending = false;
254
+
255
+ /** First child at or after `start` that is already attached natively. */
256
+ function nextNativeSibling(children: DomNode[], start: number): DomNode | undefined {
257
+ for (let i = start; i < children.length; i++) {
258
+ const child = children[i];
259
+ if (child && !pendingAppendChildren.has(child)) {
260
+ return child;
261
+ }
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ function warnUnorderedAppendOnlyInsert(parent: DomNode, node: DomNode, anchor: DomNode): void {
267
+ solidLogger.warn(
268
+ () =>
269
+ `${parent.t} only appends children: ${node.t} placed before ${anchor.t} may not keep that position in SceneGraph`
270
+ );
271
+ }
272
+
273
+ function insertIntoAppendOnlyParent(parent: DomNode, node: DomNode, anchor: DomNode | undefined): void {
274
+ const children = (parent.c = parent.c || []);
275
+ const nodeIdx = children.indexOf(node);
276
+ if (nodeIdx !== -1) {
277
+ children.splice(nodeIdx, 1);
278
+ }
279
+ node.parent = parent;
280
+ let anchorIdx = anchor ? children.indexOf(anchor) : -1;
281
+ if (anchorIdx === -1) {
282
+ anchorIdx = children.length;
283
+ }
284
+ children.splice(anchorIdx, 0, node);
285
+
286
+ if (nodeIdx === -1 || (appendsPending && pendingAppendChildren.has(node))) {
287
+ appendsPending = true;
288
+ pendingAppendChildren.add(node);
289
+ pendingAppendParents.add(parent);
290
+ solidLogger.trace(() => `insertNode: ${node.t} deferred to commit in parent: ${parent.t}`);
291
+ return;
292
+ }
293
+
294
+ // Moving an attached child: pending siblings are not in SceneGraph yet, so anchor on the next attached one.
295
+ const next = nextNativeSibling(children, anchorIdx + 1);
296
+ if (next) {
297
+ warnUnorderedAppendOnlyInsert(parent, node, next);
298
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, node.r, next.r);
299
+ } else {
300
+ tmApi.appendNode(NO_ANCHOR, parent.r, node.r);
301
+ }
302
+ solidLogger.trace(() => `insertNode: moved ${node.t} in parent: ${parent.t}`);
303
+ }
304
+
305
+ function resetPendingAppends(): void {
306
+ pendingAppendParents.clear();
307
+ pendingAppendChildren.clear();
308
+ appendsPending = false;
309
+ }
310
+
311
+ function flushPendingAppends(): void {
312
+ pendingAppendParents.forEach(parent => {
313
+ const children = parent.c ?? [];
314
+ for (let i = 0; i < children.length; i++) {
315
+ const child = children[i];
316
+ if (!child || child.parent !== parent || !pendingAppendChildren.delete(child)) {
317
+ continue;
318
+ }
319
+ const next = nextNativeSibling(children, i + 1);
320
+ if (next) {
321
+ warnUnorderedAppendOnlyInsert(parent, child, next);
322
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, child.r, next.r);
323
+ } else {
324
+ tmApi.appendNode(NO_ANCHOR, parent.r, child.r);
325
+ }
326
+ }
327
+ });
328
+ resetPendingAppends();
329
+ }
330
+
331
+ function getParent(node: DomNode): DomNode | undefined {
332
+ return node.parent;
333
+ }
334
+
335
+ // TODO: Time this operation
336
+ const removeNodeRecursively = (node: DomNode) => {
337
+ const attachParent = node.parent;
338
+ const stack = [node];
339
+ while (stack.length > 0) {
340
+ const currentNode = stack.pop();
341
+ if (!currentNode) {
342
+ continue;
343
+ }
344
+ if (currentNode.c) {
345
+ stack.push(...currentNode.c);
346
+ }
347
+
348
+ focusNodeRemoved(currentNode, attachParent);
349
+
350
+ if (currentNode.r) {
351
+ observerCallbacks.delete(currentNode.r);
352
+ // NOTE: We can call unregisterFieldHandler here.
353
+ // We are not doing it because removeNode must anyway unregister all the field handlers on native side
354
+
355
+ // clear parent pointer
356
+ currentNode.parent = undefined;
357
+ }
358
+ }
359
+ };
360
+
361
+ // Invoked only from unit tests
362
+ export function cleanUp(): void {
363
+ nodesToRemove.clear();
364
+ resetPendingAppends();
365
+ resetFocus();
366
+ }
367
+
368
+ // Cache for event handler property name classification
369
+ const isEventHandlerCache = new Map<string, boolean>();
370
+ const eventNameTransformCache = new Map<string, string>();
371
+
372
+ function isEventHandler(key: string): boolean {
373
+ let result = isEventHandlerCache.get(key);
374
+ if (result === undefined) {
375
+ // Pattern: on[A-Z] - onStatus, onFocus, etc.
376
+ // Use charAt so noUncheckedIndexedAccess does not widen key[i] to string | undefined.
377
+ result =
378
+ key.length > 2 &&
379
+ key.charAt(0) === "o" &&
380
+ key.charAt(1) === "n" &&
381
+ key.charAt(2) >= "A" &&
382
+ key.charAt(2) <= "Z";
383
+ isEventHandlerCache.set(key, result);
384
+ }
385
+ return result;
386
+ }
387
+
388
+ function transformEventName(eventName: string): string {
389
+ let transformed = eventNameTransformCache.get(eventName);
390
+ if (transformed === undefined) {
391
+ // onFocus -> focus, onLoadStatus -> loadStatus
392
+ transformed = eventName.slice(2).charAt(0).toLowerCase() + eventName.slice(3);
393
+ eventNameTransformCache.set(eventName, transformed);
394
+ }
395
+ return transformed;
396
+ }
397
+
398
+ const tagAliases = new Map<string, () => string>();
399
+
400
+ /**
401
+ * Route a JSX tag to a different SceneGraph node type, decided when each node is created.
402
+ * `resolve` runs on every creation of `tag`, so it must be cheap; it may throw to refuse
403
+ * creation outright. A later registration for the same tag replaces the earlier one.
404
+ *
405
+ * The created node's `t` is the resolved type, not `tag`: a check such as `node.t === "tween"`
406
+ * misses a node created as its shim, so match every type the alias can resolve to.
407
+ *
408
+ * @param tag - The JSX tag as written in source, e.g. `"tween"`.
409
+ * @param resolve - Returns the SceneGraph node type to create for `tag`.
410
+ */
411
+ export const registerTagAlias = (tag: string, resolve: () => string): void => {
412
+ tagAliases.set(tag, resolve);
413
+ };
414
+
415
+ /** Whether {@link registerTagAlias} has been called for `tag`. */
416
+ export const hasTagAlias = (tag: string): boolean => tagAliases.has(tag);
417
+
418
+ export const tmTransaction: TmTransaction = {
419
+ createElement: (tag: string): DomNode => {
420
+ const sgType = tagAliases.size === 0 ? tag : (tagAliases.get(tag)?.() ?? tag);
421
+ const tagRef: TmStringRef | undefined = stringInterner.internString(sgType);
422
+ if (tagRef === undefined) {
423
+ throw new Error(`Tag not interned: ${sgType}`);
424
+ }
425
+ const nodeRef: TmNodeRef = tmApi.createNode(NO_ANCHOR, tagRef);
426
+ const element: DomNode = createDomNode(sgType, nodeRef);
427
+ solidLogger.trace(() => `createElement: ${tag === sgType ? tag : `${tag} as ${sgType}`}`);
428
+ return element;
429
+ },
430
+ setProperty: (node: DomNode, name: string, value: TmValue, prev?: TmValue): void => {
431
+ const nodeRef = node.r;
432
+ let eventProp = false;
433
+ if (isEventHandler(name)) {
434
+ name = transformEventName(name);
435
+ eventProp = true;
436
+ }
437
+ const propRef: TmStringRef | undefined = stringInterner.internString(name);
438
+ if (propRef === undefined) {
439
+ throw new Error(`Property not interned: ${name}`);
440
+ }
441
+
442
+ solidLogger.trace(() => `setProperty: node: ${node.t}, name: ${name}, value: ${value}, prev: ${prev}`);
443
+ if (eventProp) {
444
+ if (typeof prev === "function") {
445
+ tmApi.unregisterFieldHandler(NO_ANCHOR, node.r, propRef);
446
+ }
447
+
448
+ if (typeof value === "function") {
449
+ tmApi.registerFieldChangeHandler(NO_ANCHOR, node.r, propRef);
450
+ }
451
+
452
+ let callbacks = observerCallbacks.get(nodeRef);
453
+
454
+ if (!callbacks) {
455
+ callbacks = new WeakMap<TmStringRef, EventCallback>();
456
+ observerCallbacks.set(nodeRef, callbacks);
457
+ }
458
+ callbacks.set(propRef, value as () => void);
459
+ return;
460
+ } else {
461
+ switch (typeof value) {
462
+ case "boolean":
463
+ if (name === "gainFocus") {
464
+ if (value) {
465
+ requestFocus(node);
466
+ } else {
467
+ releaseFocus(node);
468
+ }
469
+ break;
470
+ }
471
+ tmApi.setNodePropBool(NO_ANCHOR, nodeRef, propRef, value);
472
+ break;
473
+ case "number":
474
+ if (Number.isInteger(value)) {
475
+ tmApi.setNodePropInt(NO_ANCHOR, nodeRef, propRef, value);
476
+ } else {
477
+ tmApi.setNodePropFloat(NO_ANCHOR, nodeRef, propRef, value);
478
+ }
479
+ break;
480
+ case "string":
481
+ tmApi.setNodePropString(NO_ANCHOR, nodeRef, propRef, value);
482
+ break;
483
+ case "object":
484
+ if (value === null) {
485
+ tmApi.setNodePropNull(NO_ANCHOR, nodeRef, propRef);
486
+ } else if (Array.isArray(value)) {
487
+ const arrayRef = createFullArray(value);
488
+ tmApi.setNodePropArray(NO_ANCHOR, nodeRef, propRef, arrayRef);
489
+ } else if (isDomNode(value as DomNode)) {
490
+ tmApi.setNodePropNodeRef(NO_ANCHOR, nodeRef, propRef, (value as DomNode).r);
491
+ } else {
492
+ const objectRef: TmObjectRef = createFullObject(value);
493
+ tmApi.setNodePropObject(NO_ANCHOR, nodeRef, propRef, objectRef);
494
+ }
495
+ break;
496
+ }
497
+ }
498
+
499
+ if (typeof prev === "object" && isDomNode(prev as DomNode)) {
500
+ nodesToRemove.add(prev as DomNode);
501
+ }
502
+ },
503
+ insertNode: (parent: DomNode, node: DomNode, anchor?: DomNode): void => {
504
+ // Do nothing if the node is anchor itself
505
+ if (node === anchor) {
506
+ return;
507
+ }
508
+ if (parent.t === "panelSet") {
509
+ insertIntoAppendOnlyParent(parent, node, anchor);
510
+ nodesToRemove.delete(node);
511
+ return;
512
+ }
513
+ // Remove the node if its already in the parent
514
+ if (parent.c) {
515
+ const nodeIdx = parent.c.indexOf(node);
516
+ if (nodeIdx !== -1) {
517
+ parent.c.splice(nodeIdx, 1);
518
+ }
519
+ }
520
+
521
+ // Plain assignment — `parent` slot is pre-defined as non-enumerable
522
+ // in createDomNode, so this hits the fast IC path.
523
+ node.parent = parent;
524
+ if (anchor && parent.c) {
525
+ const anchorIdx = parent.c.indexOf(anchor);
526
+ if (anchorIdx !== -1) {
527
+ parent.c.splice(anchorIdx, 0, node);
528
+ }
529
+ tmApi.insertNodeBefore(NO_ANCHOR, parent.r, node.r, anchor.r);
530
+ solidLogger.trace(() => `insertNode: ${node.t} before anchor: ${anchor.t} in parent: ${parent.t}`);
531
+ } else {
532
+ parent.c = parent.c || [];
533
+ parent.c.push(node);
534
+ tmApi.appendNode(NO_ANCHOR, parent.r, node.r);
535
+ solidLogger.trace(() => `appendNode: ${node.t} to parent: ${parent.t}`);
536
+ }
537
+
538
+ if (nodesToRemove.has(node)) {
539
+ nodesToRemove.delete(node);
540
+ }
541
+ },
542
+ removeNode: (parent: DomNode, child: DomNode): void => {
543
+ if (parent.c) {
544
+ const childIdx = parent.c.indexOf(child);
545
+ if (childIdx !== -1) {
546
+ parent.c.splice(childIdx, 1);
547
+ }
548
+ }
549
+
550
+ nodesToRemove.add(child);
551
+ },
552
+ // Note: Precomit is used to remove nodes & set focus.
553
+ // Node removal is done later as solid.js can remove & attach nodes in the same transaction.
554
+ // Focus is set at the end of the transaction as focus changes are lost of setFocus is called in middle of the transaction.
555
+ preCommit: () => {
556
+ nodesToRemove.forEach(node => {
557
+ const parent = getParent(node);
558
+ // NOTE: Parent might be missing if the node is assigned as as prop
559
+ if (parent && !(appendsPending && parent.t === "panelSet" && pendingAppendChildren.has(node))) {
560
+ tmApi.removeNode(NO_ANCHOR, parent.r, node.r);
561
+ solidLogger.trace(() => `removeNode: ${node.t} from parent: ${parent.t}`);
562
+ }
563
+ removeNodeRecursively(node);
564
+ });
565
+ if (appendsPending) {
566
+ flushPendingAppends();
567
+ }
568
+
569
+ commitFocus();
570
+
571
+ nodesToRemove.clear();
572
+ sdCommit();
573
+ }
574
+ };
575
+
576
+ registerPreCommitHook(tmTransaction.preCommit);
577
+ setSetPropertyHandler((node, name, value) => {
578
+ transactionManager.startTransaction();
579
+ tmTransaction.setProperty(node, name, value);
580
+ });
581
+ setFocusHandler((focusNode: DomNode) => {
582
+ transactionManager.startTransaction();
583
+ requestFocus(focusNode);
584
+ });
585
+ setBlurHandler((blurNode: DomNode) => {
586
+ transactionManager.startTransaction();
587
+ releaseFocus(blurNode);
588
+ solidLogger.trace(() => `setBlur: ${blurNode.t}`);
589
+ });
590
+
591
+ function createSolidRenderer(): Renderer<DomNode> {
592
+ return createRenderer<DomNode>({
593
+ createElement(tag: string): DomNode {
594
+ transactionManager.startTransaction();
595
+ return tmTransaction.createElement(tag);
596
+ },
597
+ createTextNode(value: string): DomNode {
598
+ if (value !== "") {
599
+ solidLogger.warn(
600
+ () => `Text content "${value}" is not rendered on Roku SceneGraph. Use <label> for text display.`
601
+ );
602
+ }
603
+ transactionManager.startTransaction();
604
+ return tmTransaction.createElement("group");
605
+ },
606
+ replaceText(textNode: DomNode, value: string): void {
607
+ solidLogger.trace(() => `replaceText: textNode: ${textNode} value: ${value}`);
608
+ return;
609
+ },
610
+ isTextNode(node: DomNode): boolean {
611
+ solidLogger.trace(() => `isTextNode node: ${JSON.stringify(node)}`);
612
+ return false;
613
+ },
614
+ setProperty<T>(node: DomNode, name: string, value: T, prev?: T): void {
615
+ transactionManager.startTransaction();
616
+ tmTransaction.setProperty(node, name, value as TmValue, prev as TmValue);
617
+ },
618
+ insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void {
619
+ transactionManager.startTransaction();
620
+ tmTransaction.insertNode(parent, node, anchor);
621
+ },
622
+ removeNode(parent: DomNode, child: DomNode): void {
623
+ transactionManager.startTransaction();
624
+ tmTransaction.removeNode(parent, child);
625
+ },
626
+ getParentNode(node: DomNode): DomNode | undefined {
627
+ return getParent(node);
628
+ },
629
+ getFirstChild(node: DomNode): DomNode | undefined {
630
+ return node?.c?.[0] ?? undefined;
631
+ },
632
+ getNextSibling(node: DomNode): DomNode | undefined {
633
+ const parent = getParent(node);
634
+ if (parent && parent.c) {
635
+ return parent.c[parent.c.indexOf(node) + 1] ?? undefined;
636
+ }
637
+ return undefined;
638
+ }
639
+ });
640
+ }
641
+
642
+ function destroySolidRenderer(): void {
643
+ // no-op
644
+ }
645
+
646
+ function makeRendererFactory(
647
+ type: string,
648
+ api: TreeManipulationApi,
649
+ incomingSDApi: StructuredDataApi,
650
+ outgoingSDApi: StructuredDataApi
651
+ ): MakeRenderer {
652
+ tmApi = api;
653
+ initFocus(api, solidLogger, NO_ANCHOR);
654
+ ({ createFullArray, createFullObject, commit: sdCommit } = createSDHelper(outgoingSDApi));
655
+
656
+ const fieldChangeHandler = (() => {
657
+ const invokeCallback = (_anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: PropValue) => {
658
+ const callbacks = observerCallbacks.get(nodeRef);
659
+ if (callbacks) {
660
+ const callback = callbacks.get(fieldRef);
661
+ if (callback && typeof callback === "function") {
662
+ callback(value);
663
+ }
664
+ }
665
+ };
666
+
667
+ return {
668
+ onNodeNullFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef) => {
669
+ invokeCallback(anRef, nodeRef, fieldRef, null);
670
+ },
671
+ onNodeBoolFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: boolean) => {
672
+ invokeCallback(anRef, nodeRef, fieldRef, value);
673
+ },
674
+ onNodeIntFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: number) => {
675
+ invokeCallback(anRef, nodeRef, fieldRef, value);
676
+ },
677
+ onNodeFloatFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: number) => {
678
+ invokeCallback(anRef, nodeRef, fieldRef, value);
679
+ },
680
+ onNodeStringFieldChange: (anRef: AnNodeRef, nodeRef: TmNodeRef, fieldRef: TmStringRef, value: string) => {
681
+ invokeCallback(anRef, nodeRef, fieldRef, value);
682
+ },
683
+ onNodeObjectFieldChange: (
684
+ anRef: AnNodeRef,
685
+ nodeRef: TmNodeRef,
686
+ fieldRef: TmStringRef,
687
+ valueRef: TmObjectRef
688
+ ) => {
689
+ const data = incomingSDApi.getObject(valueRef);
690
+ if (!data) {
691
+ solidLogger.error(() => `Failed to get object from ref`);
692
+ return;
693
+ }
694
+ invokeCallback(anRef, nodeRef, fieldRef, data as PropValue);
695
+ },
696
+ onNodeArrayFieldChange: (
697
+ anRef: AnNodeRef,
698
+ nodeRef: TmNodeRef,
699
+ fieldRef: TmStringRef,
700
+ valueRef: TmArrayRef
701
+ ) => {
702
+ const data = incomingSDApi.getArray(valueRef);
703
+ if (!data) {
704
+ solidLogger.error(() => `Failed to get array from ref`);
705
+ return;
706
+ }
707
+ invokeCallback(anRef, nodeRef, fieldRef, data as PropValue);
708
+ }
709
+ };
710
+ })();
711
+
712
+ return {
713
+ createRenderer: createSolidRenderer,
714
+ destroyRenderer: destroySolidRenderer,
715
+ tmFieldChangeHandlerApi: fieldChangeHandler
716
+ };
717
+ }
718
+
719
+ function throwUninitializedRendererHook(hookName: string): never {
720
+ throw new Error(
721
+ `@roku-sdk/solid internal hook "${hookName}" was called before renderer initialization. ` +
722
+ `Ensure makeSolidRenderer() runs via @roku-sdk/rsg-ts setUp() before rendering JSX.`
723
+ );
724
+ }
725
+
726
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
727
+ function uninitializedHook(name: string): (...args: any[]) => never {
728
+ return () => throwUninitializedRendererHook(name);
729
+ }
730
+
731
+ export let effect: <T>(fn: (prev?: T) => T, init?: T) => void,
732
+ memo: <T>(fn: () => T, equal: boolean) => () => T,
733
+ createComponent: <T>(Comp: (props: T) => DomNode, props: T) => DomNode,
734
+ createElement: (tag: string) => DomNode,
735
+ createTextNode: (value: string) => DomNode,
736
+ insertNode: (parent: DomNode, node: DomNode, anchor?: DomNode) => void,
737
+ insert: <T>(parent: DomNode, accessor: (() => T) | T, marker?: DomNode | null) => DomNode,
738
+ spread: <T>(node: DomNode, accessor: (() => T) | T, skipChildren?: boolean) => void,
739
+ setProp: <T>(node: DomNode, name: string, value: T, prev?: T) => T,
740
+ use: <A, T>(fn: (element: DomNode, arg: A) => T, element: DomNode, arg: A) => T;
741
+
742
+ // Guard all hooks against accidental use before makeSolidRenderer() wires them.
743
+ effect = uninitializedHook("effect") as typeof effect;
744
+ memo = uninitializedHook("memo") as typeof memo;
745
+ createComponent = uninitializedHook("createComponent") as typeof createComponent;
746
+ createElement = uninitializedHook("createElement") as typeof createElement;
747
+ createTextNode = uninitializedHook("createTextNode") as typeof createTextNode;
748
+ insertNode = uninitializedHook("insertNode") as typeof insertNode;
749
+ insert = uninitializedHook("insert") as typeof insert;
750
+ spread = uninitializedHook("spread") as typeof spread;
751
+ setProp = uninitializedHook("setProp") as typeof setProp;
752
+ use = uninitializedHook("use") as typeof use;
753
+
754
+ export function makeSolidRenderer(
755
+ api: TreeManipulationApi,
756
+ incomingSDApi: StructuredDataApi,
757
+ outgoingSDApi: StructuredDataApi
758
+ ): SolidRenderer {
759
+ // No reason to destroy the renderer. Dispose function need to be called to clean up the resources
760
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
761
+ const { createRenderer, destroyRenderer, tmFieldChangeHandlerApi } = makeRendererFactory(
762
+ "solid",
763
+ api,
764
+ incomingSDApi,
765
+ outgoingSDApi
766
+ );
767
+ const renderer = createRenderer();
768
+ ({ effect, memo, createComponent, createElement, createTextNode, insertNode, insert, spread, setProp, use } =
769
+ renderer);
770
+
771
+ // Wrap spread to ensure event handlers are registered before regular props
772
+ const originalSpread = spread;
773
+ spread = <T>(node: DomNode, accessor: (() => T) | T, skipChildren?: boolean) => {
774
+ // Helper to create reordered proxy from props object
775
+ const createReorderedProxy = (propsObj: Record<string, unknown>) => {
776
+ const keys = Object.keys(propsObj);
777
+
778
+ // If empty object or only one property, no need to reorder
779
+ if (keys.length <= 1) {
780
+ return propsObj;
781
+ }
782
+
783
+ const eventHandlers: string[] = [];
784
+ const regularProps: string[] = [];
785
+ let needsReorder = false;
786
+ let seenRegularProp = false;
787
+
788
+ // Partition using cached results and check if already ordered
789
+ for (let i = 0; i < keys.length; i++) {
790
+ const key = keys[i];
791
+ if (key === undefined) {
792
+ continue;
793
+ }
794
+ if (isEventHandler(key)) {
795
+ // If we've already seen a regular prop, order is wrong
796
+ if (seenRegularProp) {
797
+ needsReorder = true;
798
+ }
799
+ eventHandlers.push(key);
800
+ } else {
801
+ seenRegularProp = true;
802
+ regularProps.push(key);
803
+ }
804
+ }
805
+
806
+ // If all same type, no need to reorder
807
+ if (eventHandlers.length === 0 || regularProps.length === 0) {
808
+ return propsObj;
809
+ }
810
+
811
+ // If already in correct order, return original object
812
+ if (!needsReorder) {
813
+ return propsObj;
814
+ }
815
+
816
+ // Create ordered keys array: event handlers first, then regular props
817
+ const orderedKeys = [...eventHandlers, ...regularProps];
818
+
819
+ // Create a Proxy that preserves reactive getters but controls key iteration order
820
+ return new Proxy(propsObj, {
821
+ ownKeys: () => orderedKeys,
822
+ get: (target, prop) => target[prop as keyof typeof target],
823
+ getOwnPropertyDescriptor: (target, prop) => {
824
+ // Return original descriptor to preserve references and avoid breaking equality checks
825
+ if (orderedKeys.includes(prop as string)) {
826
+ return Object.getOwnPropertyDescriptor(target, prop);
827
+ }
828
+ return undefined;
829
+ }
830
+ });
831
+ };
832
+
833
+ // Handle function accessors - defer evaluation to preserve reactive tracking
834
+ if (typeof accessor === "function") {
835
+ const wrappedAccessor = () => {
836
+ const props = (accessor as () => T)();
837
+ if (props && typeof props === "object" && !Array.isArray(props)) {
838
+ return createReorderedProxy(props as Record<string, unknown>);
839
+ }
840
+ return props;
841
+ };
842
+ originalSpread(node, wrappedAccessor, skipChildren);
843
+ } else {
844
+ // Handle static objects
845
+ if (accessor && typeof accessor === "object" && !Array.isArray(accessor)) {
846
+ const reordered = createReorderedProxy(accessor as Record<string, unknown>);
847
+ originalSpread(node, reordered, skipChildren);
848
+ } else {
849
+ originalSpread(node, accessor, skipChildren);
850
+ }
851
+ }
852
+ };
853
+
854
+ // Update the renderer object with the wrapped spread
855
+ renderer.spread = spread;
856
+ return {
857
+ renderer,
858
+ tmFieldChangeHandlerApi
859
+ };
860
+ }