@symbiote-native/svelte 0.2.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 (81) 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 +48 -23
  16. package/build/components/pressable/index.svelte.d.ts +5 -1
  17. package/build/components/pressable/pressable-props.d.ts +1 -1
  18. package/build/components/safe-area-view-props.d.ts +1 -0
  19. package/build/components/scroll-view/index.svelte +54 -21
  20. package/build/components/scroll-view/sticky-header.svelte +49 -16
  21. package/build/components/section-list/index.svelte +6 -1
  22. package/build/components/switch/index.svelte +16 -5
  23. package/build/components/text-input/index.svelte +80 -8
  24. package/build/components/text-input/index.svelte.d.ts +5 -0
  25. package/build/components/touchable-highlight/index.svelte +115 -15
  26. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  27. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +8 -3
  28. package/build/components/touchable-opacity/index.svelte +113 -56
  29. package/build/components/touchable-without-feedback/index.svelte +60 -4
  30. package/build/components/virtualized-list/index.svelte +233 -70
  31. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  32. package/build/components/virtualized-section-list/index.svelte +56 -9
  33. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  34. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  35. package/build/create-portal/index.d.ts +22 -0
  36. package/build/create-portal/index.js +160 -0
  37. package/build/descriptor-to-svelte.js +15 -21
  38. package/build/dom-shim/element.d.ts +9 -3
  39. package/build/dom-shim/element.js +77 -25
  40. package/build/dom-shim/fold-host-bag.d.ts +1 -0
  41. package/build/dom-shim/fold-host-bag.js +7 -0
  42. package/build/dom-shim/index.d.ts +1 -0
  43. package/build/dom-shim/index.js +3 -0
  44. package/build/dom-shim/patch-globals.js +4 -2
  45. package/build/dom-shim/shim-node.js +11 -0
  46. package/build/dom-shim/text.js +38 -1
  47. package/build/host-instance.js +9 -5
  48. package/build/index.d.ts +6 -3
  49. package/build/index.js +20 -11
  50. package/build/modules/animated/create-animated-component.d.ts +5 -0
  51. package/build/modules/animated/create-animated-component.js +131 -0
  52. package/build/modules/animated/index.d.ts +24 -14
  53. package/build/modules/animated/index.js +30 -30
  54. package/build/modules/status-bar/index.js +1 -1
  55. package/build/modules/status-bar/index.svelte +4 -1
  56. package/build/native-view-bridge.d.ts +1 -1
  57. package/build/native-view-bridge.js +1 -1
  58. package/build/preprocessor/collapse-text-whitespace.js +20 -7
  59. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  60. package/build/preprocessor/lower-host-primitives.d.ts +10 -0
  61. package/build/preprocessor/lower-host-primitives.js +455 -0
  62. package/build/preprocessor/scoped-styles.js +141 -104
  63. package/build/register.d.ts +1 -0
  64. package/build/register.js +33 -0
  65. package/build/render.js +48 -3
  66. package/build/scope-token.d.ts +3 -2
  67. package/build/scope-token.js +6 -17
  68. package/build/state-style.d.ts +1 -0
  69. package/build/state-style.js +8 -0
  70. package/build/style-scope.d.ts +2 -1
  71. package/build/style-scope.js +10 -8
  72. package/metro-svelte-transformer.cjs +48 -14
  73. package/package.json +28 -10
  74. package/build/modules/animated/AnimatedImage.svelte +0 -115
  75. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  76. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  77. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  78. package/build/modules/animated/AnimatedText.svelte +0 -58
  79. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  80. package/build/modules/animated/AnimatedView.svelte +0 -95
  81. package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
@@ -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,14 +1,20 @@
1
1
  import { type ISymbioteNode } from '@symbiote-native/engine';
2
2
  import { ShimNode } from './shim-node';
3
3
  export type IShimPropBag = Record<string, unknown>;
4
- export declare class ShimElement extends ShimNode {
4
+ export declare abstract class ShimElementBase extends ShimNode {
5
+ }
6
+ export declare class ShimElement extends ShimElementBase {
5
7
  readonly tagName: string;
6
8
  readonly namespaceURI: string | undefined;
7
- private readonly attributes;
8
- private readonly domListeners;
9
+ private attributes;
10
+ private domListeners;
9
11
  private lastBag;
10
12
  constructor(tagName: string, namespaceURI?: string);
11
13
  get nodeName(): string;
14
+ private styleSlot;
15
+ get style(): {
16
+ cssText: string;
17
+ };
12
18
  get p(): IShimPropBag;
13
19
  set p(bag: IShimPropBag | undefined);
14
20
  setAttribute(name: string, value: string): void;
@@ -10,10 +10,15 @@
10
10
  // A per-key diff in the setter is MANDATORY (§3g(c)), not an optimization: Svelte's
11
11
  // `set_custom_element_data` has no early-out guard and runs on every effect re-fire, so
12
12
  // without the diff every prop gets rewritten whenever any one of them changes.
13
- import { createElement, dlog, isDebug, routeProp, setEventListener, toPublicInstance, } from '@symbiote-native/engine';
13
+ import { createElement, dlog, routeProp, setEventListener, toPublicInstance, } from '@symbiote-native/engine';
14
14
  import { descriptorFor } from '@symbiote-native/components';
15
15
  import { normalizeSvelteClass } from '../class-value.js';
16
+ import { foldHostBag } from './fold-host-bag.js';
16
17
  import { ShimNode } from './shim-node.js';
18
+ // The bag a not-yet-live element is diffed against in onMadeLive. A module constant rather than a
19
+ // fresh `{}` per node: it is only ever read, and the identity lets applyBagDiff skip its second
20
+ // pass outright on the create path.
21
+ const EMPTY_BAG = Object.freeze({});
17
22
  // Diagnostic-only: a process-wide sequence so every `set p` call across every shim element in a
18
23
  // log dump is individually orderable against AnimatedProps reconcile#N / AnimatedView reduced#N
19
24
  // / hostShim identity-change#N, to see which one is driving which.
@@ -21,12 +26,26 @@ let globalSetPSeq = 0;
21
26
  // `on<Name>` handlers ride inside the prop bag (idiomatic Svelte 5 callback props — see the
22
27
  // skill's §3g(c) "most of §5 collapses" note) — they are not addEventListener-style DOM
23
28
  // listeners, so they are diffed and routed exactly like any other bag key, through routeProp.
24
- export class ShimElement extends ShimNode {
29
+ // An empty layer that exists to be what `globalThis.Element` points at, and it is load-bearing.
30
+ // Svelte's `get_setters` (internal/client/dom/elements/attributes.js) walks from the ELEMENT
31
+ // INSTANCE up and STOPS when it reaches `Element.prototype`. While `Element` was `ShimElement`
32
+ // itself, the first prototype step was already the stop, so the walk collected nothing and `p` —
33
+ // which lives on `ShimElement.prototype`, one step past it — was invisible. `set_attributes` then
34
+ // fell through to `setAttribute`, which writes an inert Map, and every prop vanished with nothing
35
+ // red. The rule is therefore that `Element` must be a proper ANCESTOR of the class owning the
36
+ // setters, never that class: `.claude/rules/svelte-shim-element-global-must-be-an-ancestor.md`.
37
+ export class ShimElementBase extends ShimNode {
38
+ }
39
+ export class ShimElement extends ShimElementBase {
25
40
  tagName;
26
41
  namespaceURI;
27
- attributes = new Map();
28
- domListeners = new Map();
29
- lastBag = {};
42
+ // Both LAZY, and that is a measured decision, not a style one. A lowered primitive carries its
43
+ // whole prop surface in the `p` bag, so neither map is ever written on the create path — but an
44
+ // eager field allocated two Maps per element regardless, 18 004 of them on a 1 000-row create,
45
+ // in a window where GC is the largest single bucket (29%).
46
+ attributes;
47
+ domListeners;
48
+ lastBag = EMPTY_BAG;
30
49
  constructor(tagName, namespaceURI) {
31
50
  super();
32
51
  this.tagName = tagName;
@@ -35,66 +54,85 @@ export class ShimElement extends ShimNode {
35
54
  get nodeName() {
36
55
  return this.tagName;
37
56
  }
57
+ // `set_style` writes `dom.style.cssText`, so a shim with no `.style` THROWS rather than
58
+ // no-opping — a `style` attribute on a bare tag crashed the mount. LAZY, for the reason
59
+ // `.claude/rules/svelte-shim-is-the-per-node-create-path.md` records about the two Maps below it:
60
+ // an eager field is one object per element, ~9 000 per create, in the window where GC is the
61
+ // largest bucket. Only `set_style` touches this, and no lowered element takes that path.
62
+ styleSlot;
63
+ get style() {
64
+ return (this.styleSlot ??= { cssText: '' });
65
+ }
38
66
  // The single object-bag prop. The literal name is ours to choose (§3g(c)) — the adapter's
39
67
  // own View.svelte/Text.svelte/… emit `<symbiote-view p={bag}>`; app code never sees it.
40
68
  get p() {
41
69
  return this.lastBag;
42
70
  }
43
71
  set p(bag) {
44
- const next = normalizeBagClasses(bag ?? {});
72
+ // The fold runs HERE rather than at element creation because an alias can arrive on an update
73
+ // (`id` bound to a signal), and it runs before the diff so `lastBag` is always the folded shape
74
+ // — otherwise a seeded default would look like a change on every single set.
75
+ const next = foldHostBag(this.tagName, normalizeBagClasses(bag ?? {}));
45
76
  const prev = this.lastBag;
46
77
  this.lastBag = next;
47
- const changedKeys = diffKeys(prev, next);
48
- dlog(`ShimElement set-p#${++globalSetPSeq} tag=${this.tagName} live=${this.engineNode !== undefined} ` +
49
- `changedKeys=${changedKeys.join(',')}`);
78
+ // THUNK, not a string (see debug.ts's header): this setter runs once per element per create
79
+ // and the changed-key list is diagnostic only. Built eagerly it cost a Set, two key arrays, a
80
+ // join and a template literal on every one of 9 002 elements, with logging off.
81
+ dlog(() => `ShimElement set-p#${++globalSetPSeq} tag=${this.tagName} live=${this.engineNode !== undefined} ` +
82
+ `changedKeys=${diffKeys(prev, next).join(',')}`);
50
83
  if (this.engineNode === undefined)
51
84
  return; // not live yet — onMadeLive() replays `next` in full
52
85
  applyBagDiff(this.engineNode, prev, next);
53
86
  this.surface?.requestCommit();
54
87
  }
55
88
  setAttribute(name, value) {
56
- this.attributes.set(name, value);
89
+ (this.attributes ??= new Map()).set(name, value);
57
90
  }
58
91
  getAttribute(name) {
59
- return this.attributes.get(name) ?? null;
92
+ return this.attributes?.get(name) ?? null;
60
93
  }
61
94
  removeAttribute(name) {
62
- this.attributes.delete(name);
95
+ this.attributes?.delete(name);
63
96
  }
64
97
  addEventListener(name, handler) {
65
- this.domListeners.set(name, handler);
98
+ (this.domListeners ??= new Map()).set(name, handler);
66
99
  if (this.engineNode !== undefined)
67
100
  setEventListener(this.engineNode, name, handler);
68
101
  }
69
102
  removeEventListener(name) {
70
- this.domListeners.delete(name);
103
+ this.domListeners?.delete(name);
71
104
  if (this.engineNode !== undefined)
72
105
  setEventListener(this.engineNode, name, undefined);
73
106
  }
74
107
  cloneNode(deep) {
75
108
  const clone = new ShimElement(this.tagName, this.namespaceURI);
76
- for (const [key, value] of this.attributes)
77
- clone.attributes.set(key, value);
109
+ if (this.attributes !== undefined)
110
+ clone.attributes = new Map(this.attributes);
78
111
  if (deep === true) {
79
112
  for (const child of this.children)
80
113
  clone.appendChild(child.cloneNode(true));
81
114
  }
82
115
  return clone;
83
116
  }
84
- // toPublicInstance grafts measure/measureInWindow/measureLayout/setNativeProps/focus/blur
85
- // onto the node — the imperative API a `bind:this` host ref hands back, the same augmentation
86
- // Vue's renderer applies at its own commit point (renderer/index.ts) and React gets from
87
- // getPublicInstance. Mutates in place and returns the SAME node, so this is a safe drop-in for
88
- // the previously-bare createElement() call.
117
+ // measure/measureInWindow/measureLayout/setNativeProps/focus/blur — the imperative API a
118
+ // `bind:this` host ref hands back — ride on the engine node's prototype, so toPublicInstance is
119
+ // the identity and this reads the same as Vue's renderer (renderer/index.ts) and React's
120
+ // getPublicInstance.
89
121
  createEngineNode() {
90
122
  const descriptor = descriptorFor(this.tagName);
91
- return toPublicInstance(createElement(descriptor.component, descriptor.isText));
123
+ // The INTRINSIC TAG as the third argument, and this is the only place the tag alphabet still
124
+ // exists: `descriptor.component` is the Fabric view name (`symbiote-view` -> `RCTView`), so a
125
+ // host-behavior registry keyed by tag can only be reached from here. The argument defaults to
126
+ // `component`, so passing it changes nothing until a behavior is registered.
127
+ return toPublicInstance(createElement(descriptor.component, descriptor.isText, this.tagName));
92
128
  }
93
129
  onMadeLive() {
94
130
  const engineNode = this.engineNode;
95
131
  if (engineNode === undefined)
96
132
  return;
97
- applyBagDiff(engineNode, {}, this.lastBag);
133
+ applyBagDiff(engineNode, EMPTY_BAG, this.lastBag);
134
+ if (this.domListeners === undefined)
135
+ return;
98
136
  for (const [name, handler] of this.domListeners)
99
137
  setEventListener(engineNode, name, handler);
100
138
  }
@@ -120,11 +158,25 @@ function normalizeBagClasses(bag) {
120
158
  }
121
159
  return next;
122
160
  }
161
+ // Diagnostic only — the one caller is the `dlog` thunk above. The allocation-free twin below is
162
+ // what the hot path uses; keep them in step.
123
163
  function diffKeys(prev, next) {
124
164
  const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
125
165
  return [...keys].filter(key => prev[key] !== next[key]);
126
166
  }
167
+ // Deliberately NOT `for (const key of diffKeys(...))`: that materialized a Set, two key arrays,
168
+ // a spread and a filtered array per element. Two direct passes route exactly the same keys —
169
+ // changed-or-added from `next`, then dropped keys from `prev` as `undefined`, which is what
170
+ // `next[key]` evaluated to in the old shape.
127
171
  function applyBagDiff(engineNode, prev, next) {
128
- for (const key of diffKeys(prev, next))
129
- routeProp(engineNode, key, next[key]);
172
+ for (const key of Object.keys(next)) {
173
+ if (prev[key] !== next[key])
174
+ routeProp(engineNode, key, next[key]);
175
+ }
176
+ if (prev === EMPTY_BAG)
177
+ return;
178
+ for (const key of Object.keys(prev)) {
179
+ if (!(key in next))
180
+ routeProp(engineNode, key, undefined);
181
+ }
130
182
  }
@@ -0,0 +1 @@
1
+ export { foldHostBag, FOLD_PLAN_BY_TAG as BY_TAG, type IHostBag, } from '@symbiote-native/components/fold-host-bag';
@@ -0,0 +1,7 @@
1
+ // Moved to `@symbiote-native/components/fold-host-bag` (2026-09-01) and kept here as a re-export.
2
+ //
3
+ // It left this adapter the moment a SECOND adapter needed it: React's wrappers are being replaced
4
+ // by bare intrinsic tags, and a bare tag has no wrapper to fold in — the same "third path" this
5
+ // file was written for, arriving in an adapter that has no lowering transform either. A per-adapter
6
+ // copy of a fold driven by a shared spec is the duplication `<adapters_stay_thin>` exists to stop.
7
+ export { foldHostBag, FOLD_PLAN_BY_TAG as BY_TAG, } from '@symbiote-native/components/fold-host-bag';
@@ -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';
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // `customElements` IS patched, unlike the three above — see its own comment below.
26
26
  import { dlog } from '@symbiote-native/engine';
27
- import { ShimElement } from './element.js';
27
+ import { ShimElement, ShimElementBase } from './element.js';
28
28
  import { ShimText } from './text.js';
29
29
  import { ShimComment } from './comment.js';
30
30
  import { ShimDocumentFragment } from './document-fragment.js';
@@ -70,7 +70,9 @@ export function patchGlobals() {
70
70
  customElements: g.customElements,
71
71
  };
72
72
  g.Node = ShimNode;
73
- g.Element = ShimElement;
73
+ // ShimElementBase, NOT ShimElement: `get_setters` stops AT `Element.prototype`, so pointing this
74
+ // at the class that owns `p` hides `p` from every `set_attributes` caller. See element.ts.
75
+ g.Element = ShimElementBase;
74
76
  // Svelte's mandatory paths never distinguish HTMLElement/SVGElement from Element (we have
75
77
  // no `<svg>` primitives), so both alias the same class — real, extensible prototypes either
76
78
  // way, which is all `init_operations()` requires (§3a).
@@ -69,12 +69,23 @@ export class ShimNode {
69
69
  for (const node of nodes)
70
70
  this.appendChild(node);
71
71
  }
72
+ // The fragment branch is the rare one — every ordinary insert is a single node — but
73
+ // `normalizeInsertable` allocated a one-element array for it regardless, 23 006 of them on a
74
+ // 1 000-row create. Test the flag first and hand the node straight to insertOne.
72
75
  appendChild(node) {
76
+ if (!node.isDocumentFragment) {
77
+ this.insertOne(node, null);
78
+ return node;
79
+ }
73
80
  for (const single of normalizeInsertable(node))
74
81
  this.insertOne(single, null);
75
82
  return node;
76
83
  }
77
84
  insertBefore(node, ref) {
85
+ if (!node.isDocumentFragment) {
86
+ this.insertOne(node, ref);
87
+ return node;
88
+ }
78
89
  for (const single of normalizeInsertable(node))
79
90
  this.insertOne(single, ref);
80
91
  return node;
@@ -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,20 +15,24 @@ 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
22
- // already being an `IHostInstance`. `dom-shim/element.ts`'s `createEngineNode()` grafts
23
- // toPublicInstance onto every host node AT CREATION, so this call is idempotent (its own
24
- // isHostInstance guard short-circuits); this helper exists only to give app code a correctly
25
+ // already being an `IHostInstance`. Every engine node carries those methods on its prototype, so
26
+ // the toPublicInstance call below is the identity; this helper exists only to give app code a correctly
25
27
  // TYPED accessor off the SHIM value (`ShimElement`) instead of the bare `.engineNode` field,
26
28
  // with no `as` cast at the call site.
27
29
  export function hostInstance(shim) {
28
30
  if (shim === null || shim === undefined)
29
31
  return undefined;
30
32
  const node = shim.engineNode;
31
- return node !== undefined && isSymbioteNode(node) ? toPublicInstance(node) : undefined;
33
+ return node !== undefined && isSymbioteNode(node)
34
+ ? toPublicInstance(node)
35
+ : undefined;
32
36
  }
33
37
  export function findNodeHandle(componentOrHandle) {
34
38
  if (componentOrHandle === null || componentOrHandle === undefined)
package/build/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import './register';
1
2
  export { mount, unmount } from './render';
2
3
  export { AppRegistry, setHostRegistrar } from './modules/app-registry';
3
4
  export type { IComponentProvider, IWrapperComponentProvider, IAppParameters, IRunnable, IHostRegistrar, IRegistry, IHeadlessTask, ITaskProvider, ITaskCanceller, ITaskCancelProvider, } from './modules/app-registry';
@@ -17,12 +18,14 @@ export type { IAccessibilityChangeEvent, IAccessibilityChangeEventName, IAccessi
17
18
  export { findNodeHandle, hostInstance } from './host-instance';
18
19
  export type { IHostInstance } from './host-instance';
19
20
  export type { ShimElement } from './dom-shim';
20
- export { createTunnel, TunnelIn, TunnelOut, type ITunnel } from './create-tunnel';
21
+ export { createTunnel, TunnelIn, TunnelOut, type ITunnel, } from './create-tunnel';
22
+ export { Portal, type IPortalProps, type IPortalTarget } from './create-portal';
21
23
  export { useWindowDimensions } from './runes/use-window-dimensions.svelte';
22
24
  export { useColorScheme } from './runes/use-color-scheme.svelte';
23
- export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio } from './runes/window';
25
+ export { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, } from './runes/window';
24
26
  export { orientation, createWidthQuery } from './runes/media-query';
25
27
  export type { IOrientation, IWidthQueryBounds } from './runes/media-query';
26
28
  export type { IReactiveValue } from './runes/dimensions-value';
27
29
  export { normalizeSvelteClass, resolveSvelteClass, type ISvelteClassValue, type IClassMap, type IClassEntry, } from './class-value';
28
- export { Animated } from './modules/animated';
30
+ export { Animated, createAnimatedComponent } from './modules/animated';
31
+ export type { IAnimatedComponentProps } from './modules/animated';