@symbiote-native/solid 0.0.1 → 1.0.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 (112) hide show
  1. package/README.md +25 -14
  2. package/babel-lower-host-primitives.cjs +323 -0
  3. package/babel-preset.cjs +5 -0
  4. package/build/bootstrap.d.ts +7 -0
  5. package/build/bootstrap.js +18 -0
  6. package/build/components/activity-indicator/index.android.d.ts +2 -0
  7. package/build/components/activity-indicator/index.android.js +11 -0
  8. package/build/components/activity-indicator/index.d.ts +1 -0
  9. package/build/components/activity-indicator/index.ios.d.ts +2 -0
  10. package/build/components/activity-indicator/index.ios.js +10 -0
  11. package/build/components/activity-indicator/index.js +4 -0
  12. package/build/components/activity-indicator/shared.d.ts +8 -0
  13. package/build/components/activity-indicator/shared.js +51 -0
  14. package/build/components/button.d.ts +7 -0
  15. package/build/components/button.jsx +45 -0
  16. package/build/components/flat-list/index.d.ts +10 -0
  17. package/build/components/flat-list/index.jsx +118 -0
  18. package/build/components/image-background.d.ts +10 -0
  19. package/build/components/image-background.jsx +74 -0
  20. package/build/components/image.d.ts +9 -0
  21. package/build/components/image.js +75 -0
  22. package/build/components/input-accessory-view.d.ts +11 -0
  23. package/build/components/input-accessory-view.jsx +39 -0
  24. package/build/components/keyboard-avoiding-view.d.ts +15 -0
  25. package/build/components/keyboard-avoiding-view.jsx +152 -0
  26. package/build/components/modal.d.ts +24 -0
  27. package/build/components/modal.jsx +125 -0
  28. package/build/components/pressable.d.ts +31 -0
  29. package/build/components/pressable.jsx +211 -0
  30. package/build/components/refresh-control.d.ts +18 -0
  31. package/build/components/refresh-control.jsx +36 -0
  32. package/build/components/safe-area-view.d.ts +11 -0
  33. package/build/components/safe-area-view.jsx +24 -0
  34. package/build/components/scroll-view/index.android.d.ts +2 -0
  35. package/build/components/scroll-view/index.android.js +11 -0
  36. package/build/components/scroll-view/index.d.ts +2 -0
  37. package/build/components/scroll-view/index.ios.d.ts +2 -0
  38. package/build/components/scroll-view/index.ios.js +8 -0
  39. package/build/components/scroll-view/index.js +9 -0
  40. package/build/components/scroll-view/shared.d.ts +85 -0
  41. package/build/components/scroll-view/shared.jsx +334 -0
  42. package/build/components/scroll-view/sticky-header.d.ts +18 -0
  43. package/build/components/scroll-view/sticky-header.jsx +182 -0
  44. package/build/components/section-list/index.d.ts +6 -0
  45. package/build/components/section-list/index.jsx +8 -0
  46. package/build/components/switch/index.android.d.ts +2 -0
  47. package/build/components/switch/index.android.js +12 -0
  48. package/build/components/switch/index.d.ts +1 -0
  49. package/build/components/switch/index.ios.d.ts +2 -0
  50. package/build/components/switch/index.ios.js +11 -0
  51. package/build/components/switch/index.js +4 -0
  52. package/build/components/switch/shared.d.ts +11 -0
  53. package/build/components/switch/shared.js +87 -0
  54. package/build/components/text-input.d.ts +10 -0
  55. package/build/components/text-input.js +290 -0
  56. package/build/components/text.d.ts +26 -0
  57. package/build/components/text.jsx +27 -0
  58. package/build/components/touchable/index.d.ts +22 -0
  59. package/build/components/touchable/index.jsx +247 -0
  60. package/build/components/touchable-native-feedback/index.d.ts +16 -0
  61. package/build/components/touchable-native-feedback/index.jsx +68 -0
  62. package/build/components/view.d.ts +32 -0
  63. package/build/components/view.jsx +49 -0
  64. package/build/components/virtualized-list/index.android.d.ts +2 -0
  65. package/build/components/virtualized-list/index.android.js +9 -0
  66. package/build/components/virtualized-list/index.d.ts +1 -0
  67. package/build/components/virtualized-list/index.ios.d.ts +2 -0
  68. package/build/components/virtualized-list/index.ios.js +9 -0
  69. package/build/components/virtualized-list/index.js +4 -0
  70. package/build/components/virtualized-list/shared.d.ts +75 -0
  71. package/build/components/virtualized-list/shared.jsx +782 -0
  72. package/build/components/virtualized-section-list/index.d.ts +62 -0
  73. package/build/components/virtualized-section-list/index.jsx +145 -0
  74. package/build/components.d.ts +43 -0
  75. package/build/components.js +54 -0
  76. package/build/create-portal/index.d.ts +11 -0
  77. package/build/create-portal/index.jsx +88 -0
  78. package/build/create-tunnel/index.d.ts +13 -0
  79. package/build/create-tunnel/index.jsx +53 -0
  80. package/build/descriptor-to-solid.d.ts +3 -0
  81. package/build/descriptor-to-solid.js +91 -0
  82. package/build/host-instance.d.ts +2 -0
  83. package/build/host-instance.js +36 -0
  84. package/build/index.d.ts +31 -0
  85. package/build/index.js +98 -0
  86. package/build/jsx-runtime.d.ts +26 -0
  87. package/build/jsx-runtime.js +46 -0
  88. package/build/modules/animated/create-animated-component.d.ts +10 -0
  89. package/build/modules/animated/create-animated-component.jsx +127 -0
  90. package/build/modules/animated/index.d.ts +54 -0
  91. package/build/modules/animated/index.js +78 -0
  92. package/build/modules/app-registry/index.d.ts +7 -0
  93. package/build/modules/app-registry/index.js +37 -0
  94. package/build/modules/status-bar.d.ts +7 -0
  95. package/build/modules/status-bar.js +45 -0
  96. package/build/primitives/create-color-scheme.d.ts +3 -0
  97. package/build/primitives/create-color-scheme.js +33 -0
  98. package/build/primitives/create-window-dimensions.d.ts +3 -0
  99. package/build/primitives/create-window-dimensions.js +34 -0
  100. package/build/register.d.ts +1 -0
  101. package/build/register.js +31 -0
  102. package/build/render.d.ts +7 -0
  103. package/build/render.js +91 -0
  104. package/build/renderer.d.ts +8 -0
  105. package/build/renderer.js +259 -0
  106. package/build/state-style.d.ts +1 -0
  107. package/build/state-style.js +13 -0
  108. package/build/utils/host-ref.d.ts +3 -0
  109. package/build/utils/host-ref.js +22 -0
  110. package/build/utils/stable-keys.d.ts +1 -0
  111. package/build/utils/stable-keys.js +31 -0
  112. package/package.json +24 -8
@@ -0,0 +1,34 @@
1
+ // createWindowDimensions — the Solid twin of React's `useWindowDimensions` hook, Vue's composable,
2
+ // Svelte's rune and Angular's WindowDimensionsService, over the framework-agnostic Dimensions
3
+ // module (@symbiote-native/engine). See create-color-scheme.ts's header for why this bucket is
4
+ // `primitives/` and why the name is `create*` rather than `use*`.
5
+ //
6
+ // Returns an ACCESSOR, never a snapshot — a Solid component body runs once, so a returned metrics
7
+ // object would stay pinned to the boot orientation.
8
+ //
9
+ // Outside a component or `createRoot` the `onCleanup` has no owner: Solid warns and the Dimensions
10
+ // listener lives for the process, while the accessor itself keeps tracking.
11
+ import { createSignal, onCleanup } from 'solid-js';
12
+ import { Dimensions, } from '@symbiote-native/engine';
13
+ // Native re-emits a fresh metrics object on every 'change', including ones that touch the screen
14
+ // but not the window. Comparing by field instead of identity keeps an unchanged window from waking
15
+ // every consumer — the guard React/Vue/Angular/Svelte all write by hand, expressed here as the
16
+ // signal's own equality (the idiomatic Solid spelling).
17
+ function metricsEqual(a, b) {
18
+ return (a.width === b.width &&
19
+ a.height === b.height &&
20
+ a.scale === b.scale &&
21
+ a.fontScale === b.fontScale);
22
+ }
23
+ export function createWindowDimensions() {
24
+ const [dimensions, setDimensions] = createSignal(Dimensions.get('window'), { equals: metricsEqual });
25
+ // Seed and subscribe run in one synchronous tick, so — unlike the effect-based twins in the other
26
+ // adapters — there is no window between them for an update to be missed in, and no re-check.
27
+ const subscription = Dimensions.addEventListener('change', (set) => {
28
+ setDimensions(set.window);
29
+ });
30
+ onCleanup(() => {
31
+ subscription.remove();
32
+ });
33
+ return dimensions;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ // Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
2
+ //
3
+ // The engine's host-behavior registry is reached through a `register*` call, and CLAUDE.md records
4
+ // what happens to one of those behind a barrel: Metro turns on `inlineRequires` for PRODUCTION
5
+ // only, moving a `require` down to the first place its binding is used as a VALUE, and
6
+ // `export { X } from './x'` compiles to a lazy getter. If nothing ever names the module as a value,
7
+ // it never evaluates and the registration silently never happens — in Release builds alone,
8
+ // invisible to tsc, to vitest, and to grepping the bundle.
9
+ //
10
+ // A bare `import './register';` next to a re-export of the same specifier does NOT help: Babel
11
+ // merges the two into one dependency and the merged dependency stays lazy. The shape that works is
12
+ // this one — a module imported ONLY for its side effect and never re-exported — the same shape
13
+ // `packages/slider/src/{react,vue,svelte,angular}/index.ts` uses. `barrel-side-effect.test.ts` is
14
+ // what stops a later tidy-up from "fixing" the bare import into a re-export.
15
+ import { registerImageBehavior, registerInputAccessoryViewBehavior, registerPressableBehavior, registerSwitchBehavior, registerTextInputBehavior, } from '@symbiote-native/components';
16
+ registerPressableBehavior();
17
+ // Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` and
18
+ // keeps running its own lifecycle. One owner per node; see `component-names/shared.ts`.
19
+ registerTextInputBehavior();
20
+ // Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
21
+ // lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
22
+ registerSwitchBehavior();
23
+ // Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
24
+ // same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
25
+ // only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
26
+ // asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
27
+ registerImageBehavior();
28
+ // Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
29
+ // aliasing at all, so a wrapper-built node folding a second time moves nothing.
30
+ // `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
31
+ registerInputAccessoryViewBehavior();
@@ -0,0 +1,7 @@
1
+ import { type IRootTag, type SymbioteSurface } from '@symbiote-native/engine';
2
+ import { type Component } from 'solid-js';
3
+ export declare function mount(rootTag: IRootTag, RootComponent: Component): SymbioteSurface;
4
+ export declare function unmount(rootTag: IRootTag): void;
5
+ declare global {
6
+ var RN$stopSurface: ((surfaceId: number) => void) | undefined;
7
+ }
@@ -0,0 +1,91 @@
1
+ // Mount a Solid app onto a Fabric surface. The native host hands us a rootTag; we create a surface
2
+ // for it and let Solid's universal renderer drive the engine, which commits into
3
+ // nativeFabricUIManager — RN's own renderer never in the path.
4
+ import { createSurface, disposeRoot, dlog, reportUncaughtError, } from '@symbiote-native/engine';
5
+ import { catchError, createRoot } from 'solid-js';
6
+ import { createComponent, insert, setActiveSurface } from './renderer.js';
7
+ // One Solid root per surface, so a surface can be torn down (unmount) or cleanly re-mounted on the
8
+ // same rootTag: the bridgeless host stops and restarts a surface on Fast Refresh and on
9
+ // lifecycle/focus changes, reusing the rootTag. The value is Solid's own dispose function — the
10
+ // whole reactive root goes with it, effects and cleanups included.
11
+ const roots = new Map();
12
+ // Insertion-ordered, so the last entry is the most recently mounted surface. Kept alongside
13
+ // `roots` only to answer one question: when a surface is torn down, is there another one still
14
+ // live for the renderer to keep committing into?
15
+ const surfaces = new Map();
16
+ let activeRootTag;
17
+ function teardown(rootTag) {
18
+ const dispose = roots.get(rootTag);
19
+ if (dispose === undefined)
20
+ return;
21
+ dispose();
22
+ roots.delete(rootTag);
23
+ surfaces.delete(rootTag);
24
+ // Cleared AFTER dispose() on purpose: disposing runs user cleanups, which can still mutate the
25
+ // tree, and those mutations should reach the surface they belong to rather than be dropped by
26
+ // renderer.ts's post-unmount guard.
27
+ //
28
+ // And cleared ONLY when the surface going away is the one the renderer is actually committing
29
+ // into. Clearing unconditionally killed a surface that was still mounted: with two surfaces up,
30
+ // unmounting the FIRST left the second alive but permanently uncommitted — every later mutation
31
+ // hit renderer.ts's "mutation after unmount" guard and was silently dropped. That is reachable
32
+ // from ordinary code the moment two surfaces exist, which is exactly the createTunnel case
33
+ // (../create-tunnel): the source surface stops, the target must keep painting.
34
+ if (activeRootTag !== rootTag) {
35
+ disposeRoot(rootTag);
36
+ return;
37
+ }
38
+ const remaining = Array.from(surfaces.entries()).pop();
39
+ activeRootTag = remaining?.[0];
40
+ setActiveSurface(remaining?.[1]);
41
+ disposeRoot(rootTag);
42
+ }
43
+ export function mount(rootTag, RootComponent) {
44
+ // A re-mount on a live rootTag starts clean; otherwise the stale root double-drives the surface.
45
+ teardown(rootTag);
46
+ const surface = createSurface(rootTag);
47
+ surfaces.set(rootTag, surface);
48
+ activeRootTag = rootTag;
49
+ setActiveSurface(surface);
50
+ // `createComponent(RootComponent, {})` is exactly what compiled JSX emits for `<RootComponent />`
51
+ // — written by hand here because this file is plain TS, deliberately: render.ts is the one module
52
+ // an app's entry point imports before any JSX has been compiled, so keeping it JSX-free means it
53
+ // needs no Babel pass of its own.
54
+ //
55
+ // The surface IS the render container: a top-level insert routes to surface.appendChild
56
+ // (renderer.ts's insertNode), and the engine wraps surface.children in its synthetic flex root.
57
+ //
58
+ // `createRoot` + `insert` IS renderer.ts's `render(code, surface)`, spelled out so `catchError`
59
+ // can sit BETWEEN them. Solid's own `render` runs `insert(element, code())` — the insert lands
60
+ // outside anything `code` wrapped, so a root component returning a bare accessor would build its
61
+ // render effect under an owner with no error handler and throw past this seam. Written this way
62
+ // the insert is inside, and every computation the mount creates inherits the handler.
63
+ let unwinding;
64
+ const dispose = createRoot(disposeRootScope => {
65
+ catchError(() => {
66
+ insert(surface, createComponent(RootComponent, {}));
67
+ }, error => {
68
+ if (unwinding?.error !== error) {
69
+ unwinding = { error };
70
+ reportUncaughtError(error, { origin: 'solid render' });
71
+ }
72
+ throw error;
73
+ });
74
+ return disposeRootScope;
75
+ });
76
+ roots.set(rootTag, dispose);
77
+ return surface;
78
+ }
79
+ // Tear down a surface by rootTag: the public pair of `mount`, and the JS half of the bridgeless
80
+ // `RN$stopSurface` contract (see installStopSurfaceGlobal below).
81
+ export function unmount(rootTag) {
82
+ dlog(`solid unmount root=${rootTag}`);
83
+ teardown(rootTag);
84
+ }
85
+ function installStopSurfaceGlobal() {
86
+ globalThis.RN$stopSurface = (surfaceId) => {
87
+ unmount(surfaceId);
88
+ };
89
+ dlog('installed global.RN$stopSurface');
90
+ }
91
+ installStopSurfaceGlobal();
@@ -0,0 +1,8 @@
1
+ import { SymbioteSurface, type ISymbioteNode } from '@symbiote-native/engine';
2
+ type IHostNode = ISymbioteNode | SymbioteSurface;
3
+ export declare function setActiveSurface(surface: SymbioteSurface | undefined): void;
4
+ export declare function replaceText(textNode: IHostNode, value: string): void;
5
+ export declare function setHostPressed(node: IHostNode, pressed: boolean): void;
6
+ export declare function removeNode(parent: IHostNode, node: IHostNode): void;
7
+ export declare const render: (code: () => IHostNode, node: IHostNode) => () => void, effect: <T>(fn: (prev?: T) => T, init?: T) => void, memo: <T>(fn: () => T, equal: boolean) => () => T, createComponent: <T>(Comp: (props: T) => IHostNode, props: T) => IHostNode, createElement: (tag: string) => IHostNode, createTextNode: (value: string) => IHostNode, insertNode: (parent: IHostNode, node: IHostNode, anchor?: IHostNode | undefined) => void, insert: <T>(parent: any, accessor: T | (() => T), marker?: any | null, initial?: any) => IHostNode, spread: <T>(node: any, accessor: (() => T) | T, skipChildren?: boolean) => void, setProp: <T>(node: IHostNode, name: string, value: T, prev?: T | undefined) => T, mergeProps: (...sources: unknown[]) => unknown, use: <A, T>(fn: (element: IHostNode, arg: A) => T, element: IHostNode, arg: A) => T;
8
+ export {};
@@ -0,0 +1,259 @@
1
+ // A Solid custom renderer over @symbiote-native/engine, built on solid-js/universal's official
2
+ // createRenderer — the framework-agnostic seam Solid itself ships for non-DOM targets. Each
3
+ // RendererOptions method maps onto the engine's tiny mutation API; the engine owns all Fabric
4
+ // clone-on-write, so Solid drives the exact same retained tree React, Vue, Angular and Svelte do.
5
+ //
6
+ // THIS MODULE IS ALSO A COMPILER TARGET, which is why it exports what it exports. babel-preset-solid
7
+ // with `generate: 'universal'` rewrites every JSX element into direct calls imported from the
8
+ // `moduleName` it was configured with (../babel-preset.cjs points it here, at the ./renderer
9
+ // subpath). A component like
10
+ //
11
+ // <symbiote-view style={{ flex: 1 }}>count {n()}</symbiote-view>
12
+ //
13
+ // compiles to `createElement('symbiote-view')` + `setProp(...)` + `createTextNode('count ')` +
14
+ // `insert(el, n, null)`, all imported from this file. So the named exports at the bottom are a
15
+ // hard contract with the compiler, not a matter of taste: drop one and the app fails to bundle
16
+ // with a module-not-found on an import nobody typed. The 11 names were verified by running
17
+ // babel.transformSync over representative JSX (elements, dynamic text, <Show>, <For>, spread,
18
+ // ref) rather than read off the docs.
19
+ import { createRenderer } from 'solid-js/universal';
20
+ import { appendChild, createAnchor, createElement as createEngineElement, createRawText, dlog, insertBefore, removeChild as removeEngineChild, routeProp, setNodePressed, setProp as setEngineProp, setText as setEngineText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
21
+ import { descriptorFor } from '@symbiote-native/components';
22
+ function isSurface(node) {
23
+ return node instanceof SymbioteSurface;
24
+ }
25
+ function isRawText(node) {
26
+ return !isSurface(node) && node.component === RAW_TEXT_COMPONENT;
27
+ }
28
+ // One active surface per process. This is FORCED here, not chosen: the compiled-JSX contract above
29
+ // means every mutation arrives through module-level functions with no surface argument, so there is
30
+ // nowhere for a per-surface renderer instance to be threaded through (Vue can close over its
31
+ // surface because Vue's createRenderer is called by US, per mount). Threading it per node instead
32
+ // would mean walking `parent` to the root on EVERY setProperty to find which surface to recommit —
33
+ // too hot for the prop path. Same single-root-per-process conclusion the Svelte adapter reached for
34
+ // its own reasons (svelte-adapter-dom-shim §10).
35
+ let activeSurface;
36
+ export function setActiveSurface(surface) {
37
+ activeSurface = surface;
38
+ }
39
+ // Every mutation asks the surface to (microtask-coalesced) recommit. Solid updates fine-grained and
40
+ // often — a single signal write can touch several props across several nodes — so a burst within one
41
+ // tick collapses into a single completeRoot, which is exactly what requestCommit() is for.
42
+ //
43
+ // A missing surface is not an error: Solid disposes its reactive graph asynchronously, so a
44
+ // cleanup-driven removeNode can legitimately land after unmount() cleared the surface. Dropping the
45
+ // commit is correct there (nothing to commit into), but it stays logged rather than silent.
46
+ function requestCommit() {
47
+ if (activeSurface === undefined) {
48
+ dlog('solid mutation after unmount — commit skipped');
49
+ return;
50
+ }
51
+ activeSurface.requestCommit();
52
+ }
53
+ function siblingsOf(node) {
54
+ if (node.parent !== undefined)
55
+ return node.parent.children;
56
+ return activeSurface?.children ?? [];
57
+ }
58
+ // Hoisted out of nodeOps and exported because descriptor-to-solid.ts needs the same text-update
59
+ // path for a Descriptor's string child, and createRenderer() only hands back the 11 names the JSX
60
+ // compiler imports — replaceText is not among them. Going through here rather than calling the
61
+ // engine's setText directly is what keeps the mutation paired with requestCommit().
62
+ export function replaceText(textNode, value) {
63
+ if (isSurface(textNode))
64
+ return;
65
+ setEngineText(textNode, asText(value));
66
+ requestCommit();
67
+ }
68
+ // solid-js/universal hands createTextNode/replaceText the JSX child value UNCONVERTED, not a
69
+ // stringified one: normalizeIncomingArray() pushes `createTextNode(item)` straight from the
70
+ // children array, so `<Text>{list().length} tiles</Text>` arrives here as the NUMBER 3. On the DOM
71
+ // that is invisible — document.createTextNode coerces — which is why upstream never had to, and why
72
+ // React's and Vue's adapters (whose frameworks stringify first) never saw it either. Here the value
73
+ // lands on RCTRawText's `text`, which Fabric parses as a std::string: a number fails the
74
+ // conversion, convertRawProp logs and falls back to the DEFAULT empty string, and an empty first
75
+ // fragment then ABORTS the app inside BaseTextShadowNode::buildAttributedString — a native SIGABRT
76
+ // with nothing in the JS stack to point at. Diagnosed on the iOS simulator 2026-08-19. The
77
+ // parameter is typed `string` by RendererOptions, so the guard reads as redundant; it is not, and
78
+ // `unknown` is what makes that honest rather than a cast.
79
+ function asText(value) {
80
+ return typeof value === 'string' ? value : String(value);
81
+ }
82
+ // Hoisted and exported for the same reason as replaceText above: createRenderer() hands back only
83
+ // the 11 names the JSX compiler imports, and removeNode is not among them — but create-portal.tsx
84
+ // has to DETACH its anchor host from the portal target on cleanup. Going through here rather than
85
+ // calling the engine's removeChild directly is what keeps the mutation paired with requestCommit(),
86
+ // and keeps the surface-vs-node branch in one place.
87
+ // The REFUSED path's half of `:active`, and the reason a refusal costs the component instance
88
+ // rather than the pressed styling. A `<Pressable>` the lowering left as a component still owns its
89
+ // own press machine, and one call here puts the node into its pressed state so `.btn:active`
90
+ // applies exactly as it would on a lowered tag.
91
+ //
92
+ // Exported paired with requestCommit() for the same reason removeNode below is: the press arrives
93
+ // from a NATIVE EVENT, outside any renderer mutation, so nothing else schedules a commit. React's
94
+ // twin (`setNodeHidden` from hideInstance) needs none because its reconciler is already mid-commit
95
+ // when it calls; Solid's is not. Costs nothing when no `:active` rule is registered — the engine
96
+ // hands back the same style object and `isAlreadyPublished` turns the re-push away without dirtying
97
+ // the node.
98
+ export function setHostPressed(node, pressed) {
99
+ if (isSurface(node))
100
+ return;
101
+ setNodePressed(node, pressed);
102
+ requestCommit();
103
+ }
104
+ export function removeNode(parent, node) {
105
+ if (isSurface(node))
106
+ return;
107
+ if (isSurface(parent))
108
+ parent.removeChild(node);
109
+ else
110
+ removeEngineChild(parent, node);
111
+ requestCommit();
112
+ }
113
+ const TEXT_FOLDS = new Map([
114
+ ['ellipsizeMode', value => value ?? 'tail'],
115
+ ['allowFontScaling', value => value !== false],
116
+ ]);
117
+ function seedTextDefaults(node) {
118
+ for (const [key, fold] of TEXT_FOLDS)
119
+ setEngineProp(node, key, fold(undefined));
120
+ }
121
+ // Seeding at CREATE is not enough, and the gap is device-only. A framework that clears a prop it
122
+ // set earlier hands us an explicit `undefined` at PATCH time, and the default has to come BACK
123
+ // rather than stay cleared — RN treats a missing prop and an explicit undefined alike, and only a
124
+ // literal `false` opts out of allowFontScaling. Two Map lookups on text nodes only, and none at
125
+ // all on a View, so it stays off the hot path.
126
+ function foldTextValue(node, key, value) {
127
+ if (!node.isText)
128
+ return value;
129
+ const fold = TEXT_FOLDS.get(key);
130
+ return fold === undefined ? value : fold(value);
131
+ }
132
+ // The alias fold, at the RENDERER and not only in the transform — the defect class Angular paid for
133
+ // twice on 2026-08-31. A lowered element inherits nothing the component wrapper did, and the
134
+ // compile-time rename in `babel-lower-host-primitives.cjs` covers exactly the call sites the
135
+ // transform REWROTE: `<View id={x} />` is fine (the attribute name is renamed before the preset
136
+ // compiles it, dynamic value included), but a hand-written `<symbiote-view id="x">` is not, and it
137
+ // committed `id` — a key Fabric does not know — while the component committed `nativeID`. Measured
138
+ // by mounting both forms and diffing committed key NAMES; totals were identical and said nothing.
139
+ //
140
+ // The transform's rename STAYS. It is not redundant: it means the markup path arrives here already
141
+ // spelled `nativeID`, so the common case never takes the branch below with a key to rewrite.
142
+ //
143
+ // One string comparison rather than a Map lookup, because this sits on the per-prop write path —
144
+ // 32 001 prop writes on a benchmark create, where a Map.get is the kind of cost the engine spent
145
+ // this month removing. That is only safe while every primitive shares ONE alias pair, which is a
146
+ // property of the shared spec and not of this file, so `renderer-alias-fold.test.ts` re-derives
147
+ // both constants from `HOST_PRIMITIVES` and fails the moment a second pair appears.
148
+ const ALIAS_FROM = 'id';
149
+ const ALIAS_TO = 'nativeID';
150
+ function foldAliasKey(name) {
151
+ return name === ALIAS_FROM ? ALIAS_TO : name;
152
+ }
153
+ const nodeOps = {
154
+ createElement(tag) {
155
+ const descriptor = descriptorFor(tag);
156
+ // The TAG goes over as well, not just the resolved Fabric name. The host-behavior registry is
157
+ // keyed by intrinsic tag while a node only ever carries the resolved view name — `symbiote-
158
+ // pressable` resolves to `RCTView` — so without this the lookup asks for `RCTView` and finds
159
+ // nothing, and the press machine silently never attaches. Registering under the Fabric name
160
+ // instead would be worse: every plain `View` would get a press machine.
161
+ const node = createEngineElement(descriptor.component, descriptor.isText, tag);
162
+ if (descriptor.isText)
163
+ seedTextDefaults(node);
164
+ // Graft the imperative public-instance API (measure / setNativeProps / focus / …) onto the raw
165
+ // node so a `ref` to a host element exposes it exactly like React's getPublicInstance.
166
+ // toPublicInstance mutates in place and returns the SAME node identity, so the engine's commit
167
+ // mirror (keyed on the raw node) still resolves it. Solid hands a plain `ref={el}` this object
168
+ // by assignment, so identity survives — but it must never be put in a createStore(), whose deep
169
+ // proxy would become a different WeakMap key and silently break every imperative command.
170
+ dlog(`solid createElement ${descriptor.component} -> public instance`);
171
+ return toPublicInstance(node);
172
+ },
173
+ // Two callers, one signature. Real content: `createTextNode('count ')` for a text literal, which
174
+ // is genuine RCTRawText and must live inside a <Text>. A PLACEHOLDER: solid-js/universal's
175
+ // cleanChildren() does `replacement || createTextNode("")` to hold the position of a dynamic
176
+ // expression (<Show>, <For>, any `{...}` with following siblings) while it has nothing to show.
177
+ // An empty RCTRawText would actually PAINT in Fabric, so an empty string maps to an engine
178
+ // anchor: a real retained node that keeps sibling order correct and is skipped by the commit walk.
179
+ // Identical call and identical fix as Vue's createText (adapters/vue/src/renderer/index.ts).
180
+ createTextNode(value) {
181
+ const text = asText(value);
182
+ return text === '' ? createAnchor() : createRawText(text);
183
+ },
184
+ replaceText,
185
+ // Answers "is this node one I can write a string into", NOT "did createTextNode make it" — and
186
+ // the difference is load-bearing. solid-js/universal's insertExpression() does
187
+ // `if (node && isTextNode(node)) replaceText(node, value)` where `node` is whatever cleanChildren
188
+ // left in place, i.e. usually the empty-string ANCHOR from createTextNode above. Answering true
189
+ // for an anchor would send the runtime down replaceText() to write text into a node that is not
190
+ // an RCTRawText and never reaches Fabric at all; answering false sends it down its other branch,
191
+ // which replaces the anchor with a fresh raw-text node — the correct outcome. So this checks the
192
+ // component name, and an anchor ('#anchor') is excluded by construction.
193
+ isTextNode(node) {
194
+ return isRawText(node);
195
+ },
196
+ setProperty(node, name, value) {
197
+ if (isSurface(node))
198
+ return;
199
+ // routeProp makes the prop-vs-event decision from the node's ViewConfig (onPress on a View
200
+ // becomes a listener; onTintColor on a Switch stays a prop), and centralizes the class+style
201
+ // merge. Shared with React and Vue — never re-implement an `onX` check here
202
+ // (symbiote-engine-core §2).
203
+ routeProp(node, foldAliasKey(name), foldTextValue(node, name, value));
204
+ requestCommit();
205
+ },
206
+ insertNode(parent, node, anchor) {
207
+ // The surface is only ever the root CONTAINER, never a child or a sibling marker. Both guards
208
+ // are narrowing, not defensive: RendererOptions types every position as the same NodeType, so
209
+ // the union has to be discharged before the engine calls, which take a node.
210
+ if (isSurface(node))
211
+ return;
212
+ const before = anchor !== undefined && !isSurface(anchor) ? anchor : undefined;
213
+ if (isRawText(node) && (isSurface(parent) || !parent.isText)) {
214
+ // Fabric has no bare-text host: RCTRawText is only valid as a <Text> child. Reached by a
215
+ // dynamic expression that resolves to a string outside a <Text>, e.g.
216
+ // `<symbiote-view>{label()}</symbiote-view>`. Failing loudly at mount beats building an
217
+ // invalid tree that crashes deeper in native with a far less legible error.
218
+ throw new Error(`Text string "${String(node.props.text)}" must be rendered inside a <Text>`);
219
+ }
220
+ if (isSurface(parent)) {
221
+ if (before !== undefined)
222
+ parent.insertBefore(node, before);
223
+ else
224
+ parent.appendChild(node);
225
+ }
226
+ else if (before !== undefined) {
227
+ insertBefore(parent, node, before);
228
+ }
229
+ else {
230
+ appendChild(parent, node);
231
+ }
232
+ requestCommit();
233
+ },
234
+ removeNode,
235
+ getParentNode(node) {
236
+ if (isSurface(node))
237
+ return undefined;
238
+ return node.parent ?? activeSurface;
239
+ },
240
+ // Anchors are NOT filtered out of this or getNextSibling, deliberately. solid-js/universal keeps
241
+ // its own record of which nodes it put where and re-derives positions through these two lookups
242
+ // (cleanChildren walks getFirstChild to empty a parent; insertExpression reaches for
243
+ // getFirstChild(parent) to replace text in place). Hiding a node the runtime itself inserted
244
+ // desyncs that record from the real tree. Anchors are invisible to FABRIC — the commit walk skips
245
+ // them — not to tree traversal.
246
+ getFirstChild(node) {
247
+ return node.children[0];
248
+ },
249
+ getNextSibling(node) {
250
+ if (isSurface(node))
251
+ return undefined;
252
+ const siblings = siblingsOf(node);
253
+ const index = siblings.indexOf(node);
254
+ return index >= 0 ? siblings[index + 1] : undefined;
255
+ },
256
+ };
257
+ // The compiled-JSX contract. See the module header: these names are imported by generated code,
258
+ // so the list is fixed by babel-preset-solid, not by us.
259
+ export const { render, effect, memo, createComponent, createElement, createTextNode, insertNode, insert, spread, setProp, mergeProps, use, } = createRenderer(nodeOps);
@@ -0,0 +1 @@
1
+ export { resolveStateStyle, type IPressStateArgument, type IResolvedStateStyle, } from '@symbiote-native/components/state-style';
@@ -0,0 +1,13 @@
1
+ // Re-export only. The implementation is shared (`@symbiote-native/components`) because two adapters
2
+ // had written byte-identical copies; this file exists so the code a transform EMITS keeps importing
3
+ // from the adapter package the app already depends on, rather than reaching past it.
4
+ //
5
+ // Imported from the SUBPATH, not the barrel: every adapter barrel re-exports
6
+ // `@symbiote-native/components` wholesale, so a name on that barrel silently becomes public API on
7
+ // all five — which is how a transform internal nearly shipped as a supported export.
8
+ //
9
+ // Present on every adapter, including those with no lowering transform yet: a per-framework subpath
10
+ // that four of five declare is exactly the silent gap `tests/package-subpath-parity.test.ts` exists
11
+ // to catch, and the file costs a re-export. Deliberately NOT on the public barrel — the emitted
12
+ // code names it, app code does not.
13
+ export { resolveStateStyle, } from '@symbiote-native/components/state-style';
@@ -0,0 +1,3 @@
1
+ import type { Ref } from 'solid-js';
2
+ import type { IHostInstance } from '../host-instance';
3
+ export declare function applyHostRef(ref: Ref<IHostInstance> | undefined, node: IHostInstance): void;
@@ -0,0 +1,22 @@
1
+ // How a caller's `ref` on one of our components reaches the host element.
2
+ //
3
+ // Solid's `ref` is a COMPILE-TIME construct, not a runtime object, and that is the whole reason
4
+ // this helper exists. On a component the compiler rewrites `ref={el}` into a callback prop:
5
+ //
6
+ // <View ref={el} /> -> createComponent(View, { ref(r$) { … el = r$ } })
7
+ //
8
+ // so by the time a component body reads `props.ref`, a variable target has ALREADY become a
9
+ // function. React's RefObject / Vue's template ref have no counterpart here — there is nothing to
10
+ // write a `.current` into, and nothing for the adapter to allocate. The component's only job is to
11
+ // call what it was handed with the host node.
12
+ //
13
+ // The declared type still has to be solid-js's own `Ref<T>` union (`T | ((val: T) => void)`),
14
+ // because type-checking happens on the SOURCE the author wrote: `ref={el}` passes a variable of
15
+ // type T, and a callback-only signature would reject it before the compiler ever gets to rewrite
16
+ // it. So the union is a call-site typing requirement, and the runtime value is always the
17
+ // function branch — a non-function can only arrive from a hand-written createComponent() call,
18
+ // where there is no variable binding to assign back into, so it is ignored rather than guessed at.
19
+ export function applyHostRef(ref, node) {
20
+ if (typeof ref === 'function')
21
+ ref(node);
22
+ }
@@ -0,0 +1 @@
1
+ export declare function withStableKeys(props: () => Record<string, unknown>): () => Record<string, unknown>;
@@ -0,0 +1,31 @@
1
+ // Solid's `spread` walks only the CURRENT key set (`for (const prop in props)` in its
2
+ // spreadExpression) and has no removal pass, so a key that VANISHES between runs keeps its last
3
+ // value on the native view forever. Not hypothetical: resolveAccessibilityProps has two branches
4
+ // with different key sets — it returns the input untouched while no aria-* alias holds a value,
5
+ // and `{...props, role: undefined, …every alias blanked, accessibilityLabel: <folded>}` once one
6
+ // does (hasAnyAriaKey tests the VALUE, not key presence). So a caller whose `aria-label` signal
7
+ // goes undefined drops the `accessibilityLabel` KEY, and a screen reader keeps announcing a label
8
+ // the app already removed. React and Vue never meet this: they hand their reconciler a whole new
9
+ // prop object, and the engine's diffProps sends a vanished key down as literal null
10
+ // (symbiote-engine-core §8). Widening the bag to every key ever seen restores that — a vanished
11
+ // key arrives as `undefined`, which routeProp treats as a delete, and `spread` keeps doing all
12
+ // the actual diffing. Never hand-roll that diff instead.
13
+ //
14
+ // Lives here rather than beside either caller because BOTH reach for it: descriptorToSolid wraps
15
+ // a render fn's Descriptor props, and the host primitives (View, Text) wrap their own
16
+ // resolveAccessibilityProps output — the same function, the same two-branch key set. Any future
17
+ // Solid component that folds props through a whole-object transform belongs on this too.
18
+ export function withStableKeys(props) {
19
+ const seen = new Set();
20
+ return () => {
21
+ const next = props();
22
+ const widened = {};
23
+ for (const key of seen)
24
+ widened[key] = undefined;
25
+ for (const key of Object.keys(next)) {
26
+ seen.add(key);
27
+ widened[key] = next[key];
28
+ }
29
+ return widened;
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@symbiote-native/solid",
3
- "version": "0.0.1",
3
+ "version": "1.0.0",
4
4
  "description": "SymbioteNative's Solid adapter — a solid-js/universal createRenderer driving real native iOS/Android views through the same engine as every other adapter.",
5
+ "keywords": [
6
+ "react-native",
7
+ "symbiote-native",
8
+ "solid",
9
+ "solid-js",
10
+ "fabric",
11
+ "cross-platform",
12
+ "ios",
13
+ "android"
14
+ ],
5
15
  "license": "MIT",
6
16
  "repository": {
7
17
  "type": "git",
@@ -39,11 +49,16 @@
39
49
  "default": "./build/jsx-runtime.js"
40
50
  },
41
51
  "./babel-preset": "./babel-preset.cjs",
42
- "./metro-css-parser": "./metro-css-parser.cjs"
52
+ "./metro-css-parser": "./metro-css-parser.cjs",
53
+ "./state-style": {
54
+ "types": "./build/state-style.d.ts",
55
+ "default": "./build/state-style.js"
56
+ }
43
57
  },
44
58
  "files": [
45
59
  "build",
46
60
  "babel-preset.cjs",
61
+ "babel-lower-host-primitives.cjs",
47
62
  "metro-css-parser.cjs"
48
63
  ],
49
64
  "publishConfig": {
@@ -52,18 +67,19 @@
52
67
  "dependencies": {
53
68
  "babel-preset-solid": "^1.9.12",
54
69
  "typescript": "~6.0.0",
55
- "@symbiote-native/css-parser": "0.3.0",
56
- "@symbiote-native/components": "0.4.0"
70
+ "@symbiote-native/components": "1.0.0",
71
+ "@symbiote-native/css-parser": "0.5.0"
57
72
  },
58
73
  "peerDependencies": {
59
- "@symbiote-native/engine": ">=0.1.7",
60
74
  "solid-js": ">=1.9.0",
61
- "react-native": ">=0.86"
75
+ "react-native": ">=0.86",
76
+ "@symbiote-native/engine": "^0.4.0"
62
77
  },
63
78
  "devDependencies": {
79
+ "@babel/core": "^7.25.2",
64
80
  "solid-js": "^1.9.14",
65
- "@symbiote-native/engine": "0.2.0",
66
- "@symbiote-native/test-utils": "0.1.6"
81
+ "@symbiote-native/engine": "0.4.0",
82
+ "@symbiote-native/test-utils": "0.3.0"
67
83
  },
68
84
  "scripts": {
69
85
  "typecheck": "tsc --build",