@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.
- package/README.md +25 -14
- package/babel-lower-host-primitives.cjs +323 -0
- package/babel-preset.cjs +5 -0
- package/build/bootstrap.d.ts +7 -0
- package/build/bootstrap.js +18 -0
- package/build/components/activity-indicator/index.android.d.ts +2 -0
- package/build/components/activity-indicator/index.android.js +11 -0
- package/build/components/activity-indicator/index.d.ts +1 -0
- package/build/components/activity-indicator/index.ios.d.ts +2 -0
- package/build/components/activity-indicator/index.ios.js +10 -0
- package/build/components/activity-indicator/index.js +4 -0
- package/build/components/activity-indicator/shared.d.ts +8 -0
- package/build/components/activity-indicator/shared.js +51 -0
- package/build/components/button.d.ts +7 -0
- package/build/components/button.jsx +45 -0
- package/build/components/flat-list/index.d.ts +10 -0
- package/build/components/flat-list/index.jsx +118 -0
- package/build/components/image-background.d.ts +10 -0
- package/build/components/image-background.jsx +74 -0
- package/build/components/image.d.ts +9 -0
- package/build/components/image.js +75 -0
- package/build/components/input-accessory-view.d.ts +11 -0
- package/build/components/input-accessory-view.jsx +39 -0
- package/build/components/keyboard-avoiding-view.d.ts +15 -0
- package/build/components/keyboard-avoiding-view.jsx +152 -0
- package/build/components/modal.d.ts +24 -0
- package/build/components/modal.jsx +125 -0
- package/build/components/pressable.d.ts +31 -0
- package/build/components/pressable.jsx +211 -0
- package/build/components/refresh-control.d.ts +18 -0
- package/build/components/refresh-control.jsx +36 -0
- package/build/components/safe-area-view.d.ts +11 -0
- package/build/components/safe-area-view.jsx +24 -0
- package/build/components/scroll-view/index.android.d.ts +2 -0
- package/build/components/scroll-view/index.android.js +11 -0
- package/build/components/scroll-view/index.d.ts +2 -0
- package/build/components/scroll-view/index.ios.d.ts +2 -0
- package/build/components/scroll-view/index.ios.js +8 -0
- package/build/components/scroll-view/index.js +9 -0
- package/build/components/scroll-view/shared.d.ts +85 -0
- package/build/components/scroll-view/shared.jsx +334 -0
- package/build/components/scroll-view/sticky-header.d.ts +18 -0
- package/build/components/scroll-view/sticky-header.jsx +182 -0
- package/build/components/section-list/index.d.ts +6 -0
- package/build/components/section-list/index.jsx +8 -0
- package/build/components/switch/index.android.d.ts +2 -0
- package/build/components/switch/index.android.js +12 -0
- package/build/components/switch/index.d.ts +1 -0
- package/build/components/switch/index.ios.d.ts +2 -0
- package/build/components/switch/index.ios.js +11 -0
- package/build/components/switch/index.js +4 -0
- package/build/components/switch/shared.d.ts +11 -0
- package/build/components/switch/shared.js +87 -0
- package/build/components/text-input.d.ts +10 -0
- package/build/components/text-input.js +290 -0
- package/build/components/text.d.ts +26 -0
- package/build/components/text.jsx +27 -0
- package/build/components/touchable/index.d.ts +22 -0
- package/build/components/touchable/index.jsx +247 -0
- package/build/components/touchable-native-feedback/index.d.ts +16 -0
- package/build/components/touchable-native-feedback/index.jsx +68 -0
- package/build/components/view.d.ts +32 -0
- package/build/components/view.jsx +49 -0
- package/build/components/virtualized-list/index.android.d.ts +2 -0
- package/build/components/virtualized-list/index.android.js +9 -0
- package/build/components/virtualized-list/index.d.ts +1 -0
- package/build/components/virtualized-list/index.ios.d.ts +2 -0
- package/build/components/virtualized-list/index.ios.js +9 -0
- package/build/components/virtualized-list/index.js +4 -0
- package/build/components/virtualized-list/shared.d.ts +75 -0
- package/build/components/virtualized-list/shared.jsx +782 -0
- package/build/components/virtualized-section-list/index.d.ts +62 -0
- package/build/components/virtualized-section-list/index.jsx +145 -0
- package/build/components.d.ts +43 -0
- package/build/components.js +54 -0
- package/build/create-portal/index.d.ts +11 -0
- package/build/create-portal/index.jsx +88 -0
- package/build/create-tunnel/index.d.ts +13 -0
- package/build/create-tunnel/index.jsx +53 -0
- package/build/descriptor-to-solid.d.ts +3 -0
- package/build/descriptor-to-solid.js +91 -0
- package/build/host-instance.d.ts +2 -0
- package/build/host-instance.js +36 -0
- package/build/index.d.ts +31 -0
- package/build/index.js +98 -0
- package/build/jsx-runtime.d.ts +26 -0
- package/build/jsx-runtime.js +46 -0
- package/build/modules/animated/create-animated-component.d.ts +10 -0
- package/build/modules/animated/create-animated-component.jsx +127 -0
- package/build/modules/animated/index.d.ts +54 -0
- package/build/modules/animated/index.js +78 -0
- package/build/modules/app-registry/index.d.ts +7 -0
- package/build/modules/app-registry/index.js +37 -0
- package/build/modules/status-bar.d.ts +7 -0
- package/build/modules/status-bar.js +45 -0
- package/build/primitives/create-color-scheme.d.ts +3 -0
- package/build/primitives/create-color-scheme.js +33 -0
- package/build/primitives/create-window-dimensions.d.ts +3 -0
- package/build/primitives/create-window-dimensions.js +34 -0
- package/build/register.d.ts +1 -0
- package/build/register.js +31 -0
- package/build/render.d.ts +7 -0
- package/build/render.js +91 -0
- package/build/renderer.d.ts +8 -0
- package/build/renderer.js +259 -0
- package/build/state-style.d.ts +1 -0
- package/build/state-style.js +13 -0
- package/build/utils/host-ref.d.ts +3 -0
- package/build/utils/host-ref.js +22 -0
- package/build/utils/stable-keys.d.ts +1 -0
- package/build/utils/stable-keys.js +31 -0
- 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
|
+
}
|
package/build/render.js
ADDED
|
@@ -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,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
|
|
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/
|
|
56
|
-
"@symbiote-native/
|
|
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.
|
|
66
|
-
"@symbiote-native/test-utils": "0.
|
|
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",
|