@symbiote-native/svelte 0.2.1 → 0.3.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 (66) hide show
  1. package/build/bootstrap.js +1 -1
  2. package/build/class-value.js +1 -1
  3. package/build/components/RefreshControl.svelte +4 -2
  4. package/build/components/Text.svelte +11 -2
  5. package/build/components/View.svelte +3 -1
  6. package/build/components/activity-indicator/index.svelte +8 -2
  7. package/build/components/button.svelte +8 -3
  8. package/build/components/flat-list/index.svelte +46 -11
  9. package/build/components/image/image-logic.js +13 -3
  10. package/build/components/image-background/index.svelte +8 -2
  11. package/build/components/index.d.ts +2 -2
  12. package/build/components/input-accessory-view/index.svelte +4 -1
  13. package/build/components/keyboard-avoiding-view/index.svelte +34 -11
  14. package/build/components/modal/index.svelte +4 -1
  15. package/build/components/pressable/index.svelte +10 -3
  16. package/build/components/pressable/pressable-props.d.ts +1 -1
  17. package/build/components/scroll-view/index.svelte +54 -21
  18. package/build/components/scroll-view/sticky-header.svelte +49 -16
  19. package/build/components/section-list/index.svelte +6 -1
  20. package/build/components/switch/index.svelte +14 -3
  21. package/build/components/text-input/index.svelte +22 -6
  22. package/build/components/touchable-highlight/index.svelte +114 -15
  23. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  24. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +7 -2
  25. package/build/components/touchable-opacity/index.svelte +112 -56
  26. package/build/components/touchable-without-feedback/index.svelte +59 -4
  27. package/build/components/virtualized-list/index.svelte +233 -70
  28. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  29. package/build/components/virtualized-section-list/index.svelte +56 -9
  30. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  31. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  32. package/build/create-portal/index.d.ts +22 -0
  33. package/build/create-portal/index.js +160 -0
  34. package/build/descriptor-to-svelte.js +15 -21
  35. package/build/dom-shim/index.d.ts +1 -0
  36. package/build/dom-shim/index.js +3 -0
  37. package/build/dom-shim/text.js +38 -1
  38. package/build/host-instance.js +7 -2
  39. package/build/index.d.ts +5 -3
  40. package/build/index.js +16 -11
  41. package/build/modules/animated/create-animated-component.d.ts +5 -0
  42. package/build/modules/animated/create-animated-component.js +131 -0
  43. package/build/modules/animated/index.d.ts +24 -14
  44. package/build/modules/animated/index.js +30 -30
  45. package/build/modules/status-bar/index.js +1 -1
  46. package/build/modules/status-bar/index.svelte +4 -1
  47. package/build/native-view-bridge.d.ts +1 -1
  48. package/build/native-view-bridge.js +1 -1
  49. package/build/preprocessor/collapse-text-whitespace.js +20 -7
  50. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  51. package/build/preprocessor/scoped-styles.js +141 -104
  52. package/build/render.js +48 -3
  53. package/build/scope-token.d.ts +3 -2
  54. package/build/scope-token.js +6 -17
  55. package/build/style-scope.d.ts +2 -1
  56. package/build/style-scope.js +10 -8
  57. package/metro-svelte-transformer.cjs +1 -1
  58. package/package.json +7 -7
  59. package/build/modules/animated/AnimatedImage.svelte +0 -115
  60. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  61. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  62. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  63. package/build/modules/animated/AnimatedText.svelte +0 -58
  64. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  65. package/build/modules/animated/AnimatedView.svelte +0 -95
  66. package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
@@ -13,7 +13,11 @@
13
13
  ISection,
14
14
  } from './virtualized-section-list-props';
15
15
 
16
- export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection };
16
+ export type {
17
+ IVirtualizedSectionListProps,
18
+ IVirtualizedSectionListHandle,
19
+ ISection,
20
+ };
17
21
  </script>
18
22
 
19
23
  <script lang="ts" generics="ItemT">
@@ -30,7 +34,10 @@
30
34
  import { Platform, dlog, type ISymbioteNode } from '@symbiote-native/engine';
31
35
  import VirtualizedList from '../virtualized-list/index.svelte';
32
36
  import { pickAccessibilityProps } from '../virtualized-list/virtualized-list-props';
33
- import type { IVirtualizedListHandle, IScrollViewHandle } from '../virtualized-list/virtualized-list-props';
37
+ import type {
38
+ IVirtualizedListHandle,
39
+ IScrollViewHandle,
40
+ } from '../virtualized-list/virtualized-list-props';
34
41
  import type { IVirtualizedSectionListProps as IProps } from './virtualized-section-list-props';
35
42
  import { pickAttachmentProps } from '../../runes/attachments';
36
43
 
@@ -50,7 +57,11 @@
50
57
 
51
58
  // RN sticks section headers by default only on iOS; Android does not unless asked.
52
59
  const stickyHeaderIndices = $derived(
53
- resolveStickySectionHeaders(props.stickySectionHeadersEnabled, headerIndices, Platform.OS),
60
+ resolveStickySectionHeaders(
61
+ props.stickySectionHeadersEnabled,
62
+ headerIndices,
63
+ Platform.OS,
64
+ ),
54
65
  );
55
66
  // DIAGNOSTIC (2026-08-13, tracking why stickyHeaderIndices comes back undefined on Android
56
67
  // despite the demo passing the bare `stickySectionHeadersEnabled` shorthand): the previous
@@ -78,11 +89,35 @@
78
89
  function getEntryCount(): number {
79
90
  return entries.length;
80
91
  }
81
- function entryKeyExtractor(entry: ISectionEntry<ItemT>, index: number): string {
92
+ function entryKeyExtractor(
93
+ entry: ISectionEntry<ItemT>,
94
+ index: number,
95
+ ): string {
82
96
  return sectionEntryKey(entry, index, props.keyExtractor);
83
97
  }
84
98
 
85
- function entrySeparatorProps(entryProps: ISeparatorProps<ISectionEntry<ItemT>>): ISeparatorProps<ItemT> {
99
+ // Hand the callback `sections`, not the entries: RN's inner VirtualizedList gets
100
+ // `data={this.props.sections}` (VirtualizedSectionList.js:216) while ours streams the FLATTENED
101
+ // entries, so the same user code would otherwise see a different argument here than on RN.
102
+ //
103
+ // UPSTREAM-DIVERGENCE(react-native): the flat INDEX matches RN's (two rows per section, header
104
+ // and footer) only while the `sectionSeparator` snippet is unset. With it, flattenSections emits
105
+ // an extra 'section-separator' row per boundary that RN renders inside the neighbouring cell, so
106
+ // indices shift by one per boundary from the second section on. Deliberate - that row is how this
107
+ // adapter paints the separator; a caller combining the two must account for it.
108
+ const entryItemLayout = $derived.by(() => {
109
+ const getItemLayout = props.getItemLayout;
110
+ if (getItemLayout === undefined) return undefined;
111
+ return (
112
+ _entries: unknown,
113
+ index: number,
114
+ ): { length: number; offset: number; index: number } =>
115
+ getItemLayout(props.sections, index);
116
+ });
117
+
118
+ function entrySeparatorProps(
119
+ entryProps: ISeparatorProps<ISectionEntry<ItemT>>,
120
+ ): ISeparatorProps<ItemT> {
86
121
  return {
87
122
  ...entryProps,
88
123
  leadingItem: unwrapEntryItem(entryProps.leadingItem),
@@ -100,9 +135,15 @@
100
135
  viewPosition?: number;
101
136
  animated?: boolean;
102
137
  }): void {
103
- const flatIndex = scrollLocationToFlatIndex(headerIndices, params.sectionIndex, params.itemIndex);
138
+ const flatIndex = scrollLocationToFlatIndex(
139
+ headerIndices,
140
+ params.sectionIndex,
141
+ params.itemIndex,
142
+ );
104
143
  if (flatIndex === undefined) {
105
- dlog(`VirtualizedSectionList scrollToLocation: section ${params.sectionIndex} out of range`);
144
+ dlog(
145
+ `VirtualizedSectionList scrollToLocation: section ${params.sectionIndex} out of range`,
146
+ );
106
147
  return;
107
148
  }
108
149
  dlog(
@@ -155,7 +196,12 @@
155
196
  {:else if entry.kind === 'section-separator'}
156
197
  {@render props.sectionSeparator?.()}
157
198
  {:else}
158
- {@render props.item({ item: entry.item, index: entry.itemIndex, section: entry.section, separators })}
199
+ {@render props.item({
200
+ item: entry.item,
201
+ index: entry.itemIndex,
202
+ section: entry.section,
203
+ separators,
204
+ })}
159
205
  {/if}
160
206
  {/snippet}
161
207
 
@@ -176,7 +222,8 @@
176
222
  footer={props.footer}
177
223
  empty={props.empty}
178
224
  keyExtractor={entryKeyExtractor}
179
- stickyHeaderIndices={stickyHeaderIndices}
225
+ getItemLayout={entryItemLayout}
226
+ {stickyHeaderIndices}
180
227
  inverted={props.inverted}
181
228
  extraData={props.extraData}
182
229
  onEndReached={props.onEndReached}
@@ -1,5 +1,5 @@
1
1
  import type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection } from './virtualized-section-list-props';
2
- export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection };
2
+ export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection, };
3
3
  import { type ISymbioteNode } from '@symbiote-native/engine';
4
4
  import type { IScrollViewHandle } from '../virtualized-list/virtualized-list-props';
5
5
  import type { IVirtualizedSectionListProps as IProps } from './virtualized-section-list-props';
@@ -25,6 +25,11 @@ export interface IVirtualizedSectionListProps<ItemT> extends IAccessibilityProps
25
25
  footer?: Snippet;
26
26
  empty?: Snippet;
27
27
  keyExtractor?: (item: ItemT, index: number) => string;
28
+ getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
29
+ length: number;
30
+ offset: number;
31
+ index: number;
32
+ };
28
33
  stickySectionHeadersEnabled?: boolean;
29
34
  extraData?: unknown;
30
35
  onEndReached?: (info: {
@@ -0,0 +1,22 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+ import { SymbioteSurface, type ISymbioteNode } from '@symbiote-native/engine';
3
+ import { ShimElement } from '../dom-shim';
4
+ /**
5
+ * Where portaled content lands. React's `IPortalContainer` carries two members
6
+ * (`ISymbioteNode | SymbioteSurface`); this carries three, because Svelte's OWN handle on a
7
+ * mounted host node is the `ShimElement` a `bind:this` / `{@attach}` hands back — the same
8
+ * capability, one wrapper further out. `ISymbioteNode` stays accepted so a raw engine node
9
+ * (what `hostInstance()` returns, and what a third-party native-view package holds) is a target
10
+ * here exactly as it is on React.
11
+ */
12
+ export type IPortalTarget = ShimElement | ISymbioteNode | SymbioteSurface;
13
+ export interface IPortalProps {
14
+ /**
15
+ * An already-mounted node in this surface, or the surface itself. Reactive: pointing it at a
16
+ * different target MOVES the content, it does not re-create it.
17
+ */
18
+ mount: IPortalTarget;
19
+ /** The content to relocate. `<Portal mount={x}>…</Portal>` fills this in implicitly. */
20
+ children: Snippet;
21
+ }
22
+ export declare const Portal: Component<IPortalProps>;
@@ -0,0 +1,160 @@
1
+ // Portal — the Svelte adapter's same-surface portal: the twin of React's `createPortal`
2
+ // (adapters/react/src/create-portal/index.ts), Solid's `<Portal mount={…}>`
3
+ // (adapters/solid/src/create-portal/index.tsx) and Angular's PortalDirective/PortalOutletDirective
4
+ // pair. This adapter shipped `createTunnel` and no portal in any spelling until 2026-08-20; the
5
+ // two do NOT overlap (see the table at the bottom of this header), so the tunnel was never a
6
+ // substitute.
7
+ //
8
+ // WHY IT IS A COMPONENT (`<Portal mount={…}>`) AND NOT A CALL (`createPortal(content, target)`).
9
+ // NOT for Solid's reason. Solid had to avoid the call form because Solid evaluates JSX eagerly at
10
+ // the position it is written, so a call would BUILD the content before anything could relocate it.
11
+ // That argument does not transfer: probed against the installed svelte 5.56.8, `{#snippet
12
+ // children()}…{/snippet}` compiles to `const children = ($$anchor) => {…}` — a lazy closure that
13
+ // runs only when something calls it with an anchor, exactly like React's `children`. Svelte's
14
+ // reason is simpler and harder: **Svelte has no expression form for markup at all.** There is no
15
+ // `h()`, no JSX value; markup exists only inside a template, and the only way to hand a block of
16
+ // template to something else is a snippet prop. A `createPortal(snippet, target)` called from
17
+ // `<script>` would still have to be given a snippet — and would then need to invent its own
18
+ // lifetime, teardown and reactive ownership, all of which a component gets from the framework.
19
+ // `<Portal mount={node}>` is also the shape solid-js/web ships and the shape every community
20
+ // Svelte portal action ends up approximating, so a Svelte author already reads it.
21
+ //
22
+ // WHY THE BODY IS HAND-WRITTEN TS AND NOT A `.svelte` FILE — forced, not preferred. A `.svelte`
23
+ // template cannot express this component at all: `{@render children()}` always renders at the
24
+ // component's OWN anchor position, and the template language has no "render into node X" form.
25
+ // Choosing the destination means calling the snippet with an anchor of our own, which is
26
+ // precisely what the compiler's own `{@render}` does. Probed:
27
+ //
28
+ // {@render children()} -> $.snippet(node, () => $$props.children)
29
+ //
30
+ // So this file makes the SAME call the compiler emits and substitutes ONE argument: the anchor.
31
+ // The precedent for a hand-written component body in this adapter is
32
+ // modules/animated/create-animated-component.ts (see its header, and svelte-internal-client.d.ts
33
+ // for the narrowed declarations); the adapter is by design coupled to Svelte's private internals
34
+ // — the whole DOM shim is (svelte-adapter-dom-shim skill §0). A welcome side effect: `Portal` is
35
+ // plain TS, so it imports from ordinary TS and from vitest with no Svelte plugin in the way.
36
+ //
37
+ // SCOPE — same-surface only, matching React's boundary exactly, neither widened nor narrowed.
38
+ // `mount` must be an already-mounted node WITHIN THE SAME SURFACE as the Portal's call site
39
+ // (typically a host element you hold via `bind:this` or `{@attach}`), or that surface itself. It
40
+ // is not a route into a second, independently mount()ed surface: React's `resetAfterCommit` fires
41
+ // only for the primary root's own container, and here the equivalent is that a foreign surface
42
+ // would never be told to re-commit — a silent no-paint, not a crash. Cross-surface content
43
+ // sharing is a different mechanism: `createTunnel` (../create-tunnel).
44
+ //
45
+ // PORTAL vs TUNNEL — every row is pinned by a test, in this file's suite and the tunnel's:
46
+ //
47
+ // Reach | same surface only | any surface, incl. a separately mount()ed one
48
+ // Target must | no — any mounted node, | yes — a <TunnelOut/> must be rendered there
49
+ // cooperate | incl. one you hold a ref |
50
+ // Placement | the target's exact slot, | one collection point, registration order
51
+ // | interleaved with its own |
52
+ // | children |
53
+ // Content's | the CALL SITE (getContext | the OUT SITE (getContext resolves there)
54
+ // reactive owner | resolves there) |
55
+ // Node identity | nodes are MOVED (the old | content is re-created per TunnelOut
56
+ // | parent empties) |
57
+ import { pop, push, snippet, user_effect } from 'svelte/internal/client';
58
+ import { appendChild as engineAppendChild, removeChild as engineRemoveChild, dlog, isSymbioteNode, SymbioteSurface, } from '@symbiote-native/engine';
59
+ import { ShimComment, ShimElement, ShimNode } from '../dom-shim/index.js';
60
+ // The Svelte flavour of React's `isSymbioteNode` guard, and it needs its own wording: React tells
61
+ // the caller to check for a forgotten `.current`, which does not exist here. A Svelte host ref is
62
+ // populated by an EFFECT, so it is still `null` while the template that reads it first runs —
63
+ // gating the Portal behind `{#if target}` is the idiomatic fix (the twin of React's callback-ref
64
+ // gotcha and Solid's `<Show when={…}>`).
65
+ function assertPortalTarget(target) {
66
+ if (target instanceof ShimElement ||
67
+ target instanceof SymbioteSurface ||
68
+ isSymbioteNode(target)) {
69
+ return target;
70
+ }
71
+ throw new Error('Portal `mount` must be an already-mounted host node (a `bind:this` / `{@attach}` ref off a rendered component) or a surface — got something else. Is the ref still null because the template ran before the effect that fills it (gate the Portal behind `{#if target}`), or did you pass a CSS-selector-style string?');
72
+ }
73
+ function targetLabel(target) {
74
+ if (target instanceof SymbioteSurface)
75
+ return `surface#${target.rootTag}`;
76
+ if (target instanceof ShimElement)
77
+ return target.tagName;
78
+ return target.component;
79
+ }
80
+ // Attach the fragment host under `target` and hand back the matching detach. Three branches
81
+ // because the three target kinds sit at different layers:
82
+ //
83
+ // * ShimElement — attach in the SHIM tree and let it do the rest. Liveness is lazy there
84
+ // (shim-node.ts's makeLive), so this is the one branch that works whether or not the target
85
+ // has reached its first commit yet, and the host's surface arrives with it.
86
+ // * SymbioteSurface — no shim node exists above it, so the host is made live against the
87
+ // surface directly and appended as a top-level child, the same branch React's
88
+ // `appendChildToContainer` takes for `isSurfaceContainer`.
89
+ // * ISymbioteNode — a raw engine node carries no surface back-pointer, so the surface comes
90
+ // from the Portal's OWN call-site anchor. That is correct by construction for the only
91
+ // supported case: same-surface targets.
92
+ function attachHost(target, host, callSiteAnchor) {
93
+ if (target instanceof ShimElement) {
94
+ target.appendChild(host);
95
+ return () => {
96
+ target.removeChild(host);
97
+ };
98
+ }
99
+ if (target instanceof SymbioteSurface) {
100
+ const node = host.makeLive(target);
101
+ target.appendChild(node);
102
+ target.requestCommit();
103
+ return () => {
104
+ target.removeChild(node);
105
+ target.requestCommit();
106
+ };
107
+ }
108
+ const surface = callSiteAnchor?.surface;
109
+ if (surface === undefined) {
110
+ throw new Error('Portal `mount` was given a raw engine node, but the Portal itself is not mounted on a surface yet, so there is nothing to commit it to. Pass the `bind:this` / `{@attach}` value itself (a host element) rather than unwrapping it, or pass the surface.');
111
+ }
112
+ const node = host.makeLive(surface);
113
+ engineAppendChild(target, node);
114
+ surface.requestCommit();
115
+ return () => {
116
+ engineRemoveChild(target, node);
117
+ surface.requestCommit();
118
+ };
119
+ }
120
+ export const Portal = function Portal(internals, props) {
121
+ // The compiled twin of a component's own `$props()` scope. Without it `user_effect` below is
122
+ // created immediately instead of being deferred to mount, and would run before the call site
123
+ // has finished rendering — the same reason create-animated-component.ts opens one.
124
+ push(props, true);
125
+ // ONE engine anchor per Portal instance, as a fragment host. It is a real retained node, so
126
+ // the content has a stable exclusive parent to be reconciled under, and the commit walk
127
+ // FLATTENS an anchor's children into its parent (renderableChildren, core/engine/src/commit.ts)
128
+ // so the anchor itself never paints. That is what keeps portaled content a DIRECT Fabric child
129
+ // of the target — matching React's createPortal, and unlike a DOM portal, which always leaves
130
+ // its container element in the tree.
131
+ //
132
+ // It is also what makes relocation and teardown one call each: the whole subtree travels with
133
+ // the anchor, so nothing has to track which nodes the snippet produced.
134
+ const host = new ShimComment('symbiote-portal');
135
+ // The anchor `$.snippet` renders BEFORE — a child of the host, so the content lands inside the
136
+ // host rather than wherever the host currently sits. Also an engine anchor: flattened away too.
137
+ const renderAnchor = new ShimComment('');
138
+ host.appendChild(renderAnchor);
139
+ // A component's first argument IS its anchor node, which in this adapter is a shim node — so
140
+ // the Portal can read its own surface off it. Guarded rather than cast: `ComponentInternals` is
141
+ // an opaque branded type, and a future Svelte could hand something else.
142
+ const callSiteAnchor = internals instanceof ShimNode ? internals : undefined;
143
+ // Render FIRST, relocate second — that order is the whole reason context, error boundaries and
144
+ // ownership resolve from the call site: the snippet runs inside THIS component's context, and
145
+ // `<Portal>` is written at the call site. Only host nodes move afterwards.
146
+ snippet(renderAnchor, () => props.children);
147
+ // `props.mount` is read inside the effect, not destructured at the top: a prop arrives as a
148
+ // getter (probed — the compiler emits `get mount() { return $.get(x); }`), so reading it here
149
+ // would freeze the first target. Returning the detach as the effect's cleanup covers both
150
+ // cases at once — a `mount` change (fired before the effect re-attaches elsewhere) and the
151
+ // Portal's own teardown.
152
+ user_effect(() => {
153
+ const target = assertPortalTarget(props.mount);
154
+ dlog(`svelte portal -> ${targetLabel(target)}`);
155
+ return attachHost(target, host, callSiteAnchor);
156
+ });
157
+ // Nothing paints at the call site — React's createPortal returns a ReactPortal that renders
158
+ // nothing there for the same reason. A component's return value is its exports; there are none.
159
+ return pop({});
160
+ };
@@ -19,12 +19,12 @@
19
19
  // function's actual output changed shape, which this bridge's whole cost-free model assumes
20
20
  // never happens. If this ever fires for real, the render fn genuinely stopped being
21
21
  // shape-stable and needs its own fix, not a workaround here.
22
+ import { createDescriptorShapeGuard } from '@symbiote-native/components';
22
23
  import { getShimDocument } from './dom-shim/index.js';
23
- function shapeChangedMessage(detail) {
24
- return (`descriptorToSvelte: Descriptor shape changed between renders (${detail}) — a ` +
25
- `render-*.ts fn is expected to produce a CONSTANT tree shape (svelte-adapter-dom-shim ` +
26
- `skill §15/§19); only prop values may vary between calls.`);
27
- }
24
+ // The predicates live in @symbiote-native/components, next to the Descriptor whose contract they
25
+ // guard (skill §15/§19 for why this bridge depends on it at all). Solid's bridge had grown a
26
+ // private copy of the same checks with different coverage; one owner ends that.
27
+ const shape = createDescriptorShapeGuard('descriptorToSvelte');
28
28
  function buildChild(child) {
29
29
  const document = getShimDocument();
30
30
  if (typeof child === 'string') {
@@ -40,22 +40,17 @@ function buildChild(child) {
40
40
  return { kind: 'element', shim, children };
41
41
  }
42
42
  function syncChild(cached, child) {
43
- if (typeof child === 'string') {
44
- if (cached.kind !== 'text')
45
- throw new Error(shapeChangedMessage('text/element'));
46
- if (cached.shim.data !== child)
47
- cached.shim.data = child;
43
+ if (cached.kind === 'text') {
44
+ const text = shape.asText(child);
45
+ if (cached.shim.data !== text)
46
+ cached.shim.data = text;
48
47
  return;
49
48
  }
50
- if (cached.kind !== 'element' || cached.shim.tagName !== child.type) {
51
- const was = cached.kind === 'element' ? cached.shim.tagName : 'text';
52
- throw new Error(shapeChangedMessage(`${was} -> ${child.type}`));
53
- }
54
- cached.shim.p = child.props;
55
- if (cached.children.length !== child.children.length) {
56
- throw new Error(shapeChangedMessage('child count'));
57
- }
58
- cached.children.forEach((c, index) => syncChild(c, child.children[index]));
49
+ const element = shape.asElement(child);
50
+ shape.assertType(cached.shim.tagName, element.type);
51
+ cached.shim.p = element.props;
52
+ shape.assertChildCount(cached.children.length, element.children.length);
53
+ cached.children.forEach((c, index) => syncChild(c, element.children[index]));
59
54
  }
60
55
  // Materializes `descriptor.children` onto an already-live `parent` ONCE, then reuses the same
61
56
  // shim nodes by position on every `update()`. `parent` is expected to already be live (has an
@@ -70,8 +65,7 @@ export function mountDescriptorChildren(parent, children) {
70
65
  });
71
66
  return {
72
67
  update(next) {
73
- if (cached.length !== next.length)
74
- throw new Error(shapeChangedMessage('root child count'));
68
+ shape.assertChildCount(cached.length, next.length);
75
69
  cached.forEach((c, index) => syncChild(c, next[index]));
76
70
  },
77
71
  };
@@ -1,5 +1,6 @@
1
1
  export { patchGlobals, restoreGlobals } from './patch-globals';
2
2
  export { ShimElement } from './element';
3
+ export { ShimComment } from './comment';
3
4
  export { ShimNode } from './shim-node';
4
5
  export { ShimText } from './text';
5
6
  export { getShimDocument } from './document';
@@ -2,6 +2,9 @@
2
2
  // never sees a shim class or a `symbiote-*` tag; see the adapter's own index.ts).
3
3
  export { patchGlobals, restoreGlobals } from './patch-globals.js';
4
4
  export { ShimElement } from './element.js';
5
+ // ShimComment is the anchor path (engine createAnchor, flattened away by the commit walk) — the
6
+ // only shim node create-portal can use as a non-painting fragment host.
7
+ export { ShimComment } from './comment.js';
5
8
  export { ShimNode } from './shim-node.js';
6
9
  export { ShimText } from './text.js';
7
10
  export { getShimDocument } from './document.js';
@@ -18,6 +18,39 @@
18
18
  // native-node-parity.test.ts, which diffs committed native trees against the Vue adapter.
19
19
  import { appendChild as engineAppendChild, createAnchor, createRawText, insertBefore as engineInsertBefore, isAnchor, removeChild as engineRemoveChild, setText, } from '@symbiote-native/engine';
20
20
  import { ShimNode } from './shim-node.js';
21
+ // Deliberately WIDER than Svelte's own whitespace class. `svelte/src/compiler/phases/patterns.js`
22
+ // uses /[^ \t\r\n]/ and says why: "Not \S because that also removes explicit whitespace defined
23
+ // through things like `&nbsp;`". For Svelte the character IS the discriminator, so it must keep
24
+ // an author's deliberate nbsp. For us the PARENT is the discriminator, and it has already proved
25
+ // the node is unrenderable - a raw text under a non-text parent cannot paint whatever character
26
+ // it holds. So `&nbsp;`, `&emsp;`, \f, \v, U+2028, U+3000 and the zero-width family (which `\s`
27
+ // misses) all drop here, while a deliberate nbsp INSIDE a <Text> is kept by the parent check.
28
+ // Measured: each of these arrives as its own text node in the from_tree template.
29
+ const WHITESPACE_ONLY = /^[\s\u200b-\u200d\ufeff]+$/;
30
+ // Whitespace-only text under a parent that cannot hold raw text is FORMATTING, not content: the
31
+ // gap Svelte leaves between two sibling tags written on separate lines. Svelte collapses every
32
+ // such run to a single ' ' but never deletes it — in the DOM that space separates inline words
33
+ // and only CSS decides whether it paints. Fabric has no such layer, so a raw text outside a
34
+ // <Text> is simply invalid: the same invariant the engine enforces at commit time.
35
+ //
36
+ // The PARENT is what makes this exact rather than a heuristic. Measured on svelte 5.56.8, a
37
+ // stray gap and an {#each} text placeholder are the same ' ' string in the from_tree template:
38
+ //
39
+ // stray gap ['symbiote-view', null, [...], ' ', [...]] parent takes no raw text -> drop
40
+ // placeholder ['symbiote-text', null, ' '] parent IS a <Text> -> keep
41
+ //
42
+ // So `<Text><Text>a</Text> <Text>b</Text></Text>` keeps its separator, correctly — there the
43
+ // space really is a word boundary. This also covers the one shape the source preprocessor
44
+ // admits it cannot catch (two siblings, one line, no newline): by here Svelte has already
45
+ // normalized that form to the very same single space.
46
+ //
47
+ // makeLive() binds a parent before its children, so the parent's engine node is always present
48
+ // by the time this runs; a fragment is unwrapped into the real parent before insertion.
49
+ function isFormattingWhitespace(value, parent) {
50
+ if (!WHITESPACE_ONLY.test(value))
51
+ return false;
52
+ return parent?.engineNode?.isText !== true;
53
+ }
21
54
  export class ShimText extends ShimNode {
22
55
  value;
23
56
  constructor(value) {
@@ -92,6 +125,10 @@ export class ShimText extends ShimNode {
92
125
  return new ShimText(this.value);
93
126
  }
94
127
  createEngineNode() {
95
- return this.value === '' ? createAnchor() : createRawText(this.value);
128
+ if (this.value === '')
129
+ return createAnchor();
130
+ if (isFormattingWhitespace(this.value, this.parent))
131
+ return createAnchor();
132
+ return createRawText(this.value);
96
133
  }
97
134
  }
@@ -15,7 +15,10 @@ import { getNativeTag, isSymbioteNode, toPublicInstance, dlog, } from '@symbiote
15
15
  // and none of those is a host ref an interop library can hand back. `tagName` is ShimElement's
16
16
  // own field, so checking it makes the predicate mean what its name says.
17
17
  function isShimElement(value) {
18
- return typeof value === 'object' && value !== null && 'engineNode' in value && 'tagName' in value;
18
+ return (typeof value === 'object' &&
19
+ value !== null &&
20
+ 'engineNode' in value &&
21
+ 'tagName' in value);
19
22
  }
20
23
  // The typed imperative handle (measure/measureInWindow/measureLayout/setNativeProps/focus/blur)
21
24
  // a `bind:this` host ref carries — the Svelte twin of a React `ref.current`/Vue template ref
@@ -28,7 +31,9 @@ export function hostInstance(shim) {
28
31
  if (shim === null || shim === undefined)
29
32
  return undefined;
30
33
  const node = shim.engineNode;
31
- return node !== undefined && isSymbioteNode(node) ? toPublicInstance(node) : undefined;
34
+ return node !== undefined && isSymbioteNode(node)
35
+ ? toPublicInstance(node)
36
+ : undefined;
32
37
  }
33
38
  export function findNodeHandle(componentOrHandle) {
34
39
  if (componentOrHandle === null || componentOrHandle === undefined)
package/build/index.d.ts CHANGED
@@ -17,12 +17,14 @@ export type { IAccessibilityChangeEvent, IAccessibilityChangeEventName, IAccessi
17
17
  export { findNodeHandle, hostInstance } from './host-instance';
18
18
  export type { IHostInstance } from './host-instance';
19
19
  export type { ShimElement } from './dom-shim';
20
- export { createTunnel, TunnelIn, TunnelOut, type ITunnel } from './create-tunnel';
20
+ export { createTunnel, TunnelIn, TunnelOut, type ITunnel, } from './create-tunnel';
21
+ export { Portal, type IPortalProps, type IPortalTarget } from './create-portal';
21
22
  export { useWindowDimensions } from './runes/use-window-dimensions.svelte';
22
23
  export { useColorScheme } from './runes/use-color-scheme.svelte';
23
- export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio } from './runes/window';
24
+ export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, } from './runes/window';
24
25
  export { orientation, createWidthQuery } from './runes/media-query';
25
26
  export type { IOrientation, IWidthQueryBounds } from './runes/media-query';
26
27
  export type { IReactiveValue } from './runes/dimensions-value';
27
28
  export { normalizeSvelteClass, resolveSvelteClass, type ISvelteClassValue, type IClassMap, type IClassEntry, } from './class-value';
28
- export { Animated } from './modules/animated';
29
+ export { Animated, createAnimatedComponent } from './modules/animated';
30
+ export type { IAnimatedComponentProps } from './modules/animated';
package/build/index.js CHANGED
@@ -46,11 +46,17 @@ export { findNodeHandle, hostInstance } from './host-instance.js';
46
46
  // (mount/unmount, for a downstream package's own tests).
47
47
  // createTunnel: the Svelte twin of adapters/vue/src/create-tunnel (see create-tunnel/tunnel.ts's
48
48
  // header for why the API shape — an explicit `tunnel` prop on TunnelIn/TunnelOut, rather than
49
- // `tunnel.In`/`tunnel.Out` — deliberately differs from React/Vue). NOTE: React's createPortal has
50
- // no Svelte (or even Vue) twin — it is react-reconciler's own Fiber-level HostPortal primitive,
51
- // with no equivalent in a framework with no reconciler; createTunnel is the achievable analog,
52
- // same scope Vue itself settled on.
53
- export { createTunnel, TunnelIn, TunnelOut } from './create-tunnel/index.js';
49
+ // `tunnel.In`/`tunnel.Out` — deliberately differs from React/Vue).
50
+ export { createTunnel, TunnelIn, TunnelOut, } from './create-tunnel/index.js';
51
+ // Portal: the Svelte twin of React's createPortal / Solid's <Portal mount={…}> / Angular's
52
+ // PortalDirective. It is NOT interchangeable with createTunnel above — the two capabilities do
53
+ // not overlap (create-portal/index.ts's header carries the row-by-row table, each row pinned by a
54
+ // test). Portal uniquely buys a target that renders no outlet — the only route to a container you
55
+ // merely hold a ref to — and call-site `getContext`; the tunnel uniquely buys cross-surface reach.
56
+ // (This barrel used to state that createPortal "has no Svelte twin ... with no equivalent in a
57
+ // framework with no reconciler". That was wrong: a portal needs a retained tree you can move a
58
+ // subtree within, not a reconciler, and this adapter has one.)
59
+ export { Portal } from './create-portal/index.js';
54
60
  // useWindowDimensions / useColorScheme: the Svelte twins of React's hooks / Vue's composables —
55
61
  // see runes/use-window-dimensions.svelte.ts's header for why this adapter's lifecycle-helper
56
62
  // bucket is named `runes/`, not `hooks/`/`composables/`.
@@ -60,7 +66,7 @@ export { useColorScheme } from './runes/use-color-scheme.svelte';
60
66
  // `undefined` forever here. Same names, same `.current` shape, engine Dimensions/PixelRatio
61
67
  // underneath — see runes/window.ts's header for the two deliberate differences from upstream, and
62
68
  // for why `scrollX`/`scrollY`/`screenLeft`/`screenTop` are absent rather than faked.
63
- export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio } from './runes/window.js';
69
+ export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, } from './runes/window.js';
64
70
  // What replaces `MediaQuery` from `svelte/reactivity`: named exports for the media features RN can
65
71
  // actually answer, instead of a class taking a CSS query string it would mostly have to answer
66
72
  // `false` to. `(prefers-color-scheme)`'s twin is `useColorScheme()` above. See
@@ -70,8 +76,7 @@ export { orientation, createWidthQuery } from './runes/media-query.js';
70
76
  // IClassNameValue — plus the normalizer, for a consumer building a class value in its own
71
77
  // helper. See class-value.ts's header for the object-is-a-style vs object-is-a-clsx-map rule.
72
78
  export { normalizeSvelteClass, resolveSvelteClass, } from './class-value.js';
73
- // Animated: the Svelte twin of adapters/vue/src/modules/animated (see modules/animated/index.ts's
74
- // header for why Animated.View/Text/Image/ScrollView are each their own hand-authored .svelte
75
- // file rather than a generic createAnimatedComponent() wrap — Svelte has no runtime h()/
76
- // createElement equivalent a factory could target).
77
- export { Animated } from './modules/animated/index.js';
79
+ // Animated: the Svelte twin of adapters/vue/src/modules/animated. All six RN animated components
80
+ // ship (View/Text/Image/ScrollView/FlatList/SectionList), all of them from the same generic
81
+ // createAnimatedComponent() wrap React, Vue and Solid also export.
82
+ export { Animated, createAnimatedComponent } from './modules/animated/index.js';
@@ -0,0 +1,5 @@
1
+ import type { Component, ComponentInternals } from 'svelte';
2
+ import type { IAnimatedComponentProps } from './animated-component-props';
3
+ type IAnimatedBase<TExports extends Record<string, unknown>> = (internals: ComponentInternals, props: never) => TExports;
4
+ export declare function createAnimatedComponent<TExports extends Record<string, unknown>>(Base: IAnimatedBase<TExports>): Component<IAnimatedComponentProps, TExports>;
5
+ export {};