@symbiote-native/svelte 0.3.0 → 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.
@@ -22,15 +22,18 @@
22
22
  </script>
23
23
 
24
24
  <script lang="ts">
25
+ import { onDestroy } from 'svelte';
25
26
  import {
26
27
  createPressHandlers,
27
28
  createPressRuntime,
29
+ disposePressRuntime,
28
30
  rippleProps,
29
31
  buildPressableListeners,
30
32
  resolveDisabledAccessibilityState,
31
33
  resolveAccessibilityProps,
32
34
  noteHoverNoop,
33
35
  DEFAULT_DELAY_LONG_PRESS_MS,
36
+ DEFAULT_MIN_PRESS_DURATION_MS,
34
37
  type IPressHost,
35
38
  type IPressState,
36
39
  } from '@symbiote-native/components';
@@ -50,6 +53,7 @@
50
53
  hitSlop,
51
54
  pressRetentionOffset,
52
55
  unstable_pressDelay = 0,
56
+ __minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS,
53
57
  android_ripple,
54
58
  android_disableSound,
55
59
  onHoverIn,
@@ -61,7 +65,10 @@
61
65
  class: className,
62
66
  children,
63
67
  ...rest
64
- }: IPressableProps = $props();
68
+ }: IPressableProps & {
69
+ /** @internal Touchable* mirrors RN's Pressability minPressDuration: 0 override. */
70
+ __minPressDuration?: number;
71
+ } = $props();
65
72
 
66
73
  let pressed = $state(false);
67
74
  // Plain setup-scope object, never `$state`: mutated by the machine on every event, never
@@ -85,8 +92,13 @@
85
92
  const id = setTimeout(callback, ms);
86
93
  return () => clearTimeout(id);
87
94
  },
95
+ now: Date.now,
88
96
  };
89
97
 
98
+ onDestroy(() => {
99
+ disposePressRuntime(runtime);
100
+ });
101
+
90
102
  $effect(() => {
91
103
  noteHoverNoop(onHoverIn, onHoverOut);
92
104
  });
@@ -107,6 +119,7 @@
107
119
  onLongPress,
108
120
  delayLongPress,
109
121
  unstable_pressDelay,
122
+ minPressDuration: __minPressDuration,
110
123
  hitSlop,
111
124
  pressRetentionOffset,
112
125
  },
@@ -115,38 +128,43 @@
115
128
  ),
116
129
  );
117
130
 
118
- const resolvedAccessibilityState = $derived(
119
- resolveDisabledAccessibilityState(accessibilityState, disabled),
120
- );
121
-
122
- // Folds aria-*/role into accessibility* — Pressable owns its host node directly (it does not
123
- // compose View.svelte), so it must fold this itself, exactly like Vue's Pressable and every
124
- // other Svelte component that hand-authors a raw host tag (RefreshControl.svelte, modal/
125
- // index.svelte).
126
- const resolved = $derived(
127
- resolveAccessibilityProps({
128
- ...rest,
129
- accessibilityState: resolvedAccessibilityState,
130
- }),
131
- );
132
-
133
- const resolvedStyle = $derived(
134
- typeof style === 'function' ? style(state) : style,
135
- );
136
-
137
131
  // android_ripple rides a dedicated inner View; on iOS rippleProps() returns undefined, so the
138
132
  // child renders unwrapped, no extra node — mirrors React's Pressable + touchable-native-feedback.
139
133
  const ripple = $derived(
140
134
  android_ripple !== undefined ? rippleProps(android_ripple) : undefined,
141
135
  );
142
136
 
143
- const bag = $derived({
144
- ...resolved,
145
- style: resolvedStyle,
146
- class: className,
147
- hitSlop,
148
- ...(android_disableSound !== undefined ? { android_disableSound } : {}),
149
- ...buildPressableListeners(handlers, { disabled, cancelable }),
137
+ // ONE derived, not four. The accessibility fold and the style resolution used to be their own
138
+ // `$derived`s, each read by exactly this bag and by nothing else — a memo nobody can reuse, in
139
+ // exchange for a reaction-graph node per Pressable instance. A 1 000-row list mounts 2 000 of
140
+ // them, which made this component the largest single allocation site in a create profile.
141
+ // `handlers` and `ripple` stay separate BECAUSE they are read elsewhere: `handlers` must keep
142
+ // its identity across an unrelated bag change or every recompute re-registers native listeners,
143
+ // and `ripple` is read twice (the branch test and the inner view's own bag).
144
+ const bag = $derived.by(() => {
145
+ // Folds aria-*/role into accessibility* — Pressable owns its host node directly (it does not
146
+ // compose View.svelte), so it must fold this itself, exactly like Vue's Pressable and every
147
+ // other Svelte component that hand-authors a raw host tag (RefreshControl.svelte, modal/
148
+ // index.svelte).
149
+ const resolved = resolveAccessibilityProps({
150
+ ...rest,
151
+ accessibilityState: resolveDisabledAccessibilityState(
152
+ accessibilityState,
153
+ disabled,
154
+ ),
155
+ });
156
+ const next: Record<string, unknown> = {
157
+ ...resolved,
158
+ style: typeof style === 'function' ? style(state) : style,
159
+ class: className,
160
+ hitSlop,
161
+ ...buildPressableListeners(handlers, { disabled, cancelable }),
162
+ };
163
+ // Assigned rather than conditionally spread: `...(cond ? { x } : {})` allocated an empty
164
+ // object literal on every evaluation for the overwhelmingly common `undefined` case.
165
+ if (android_disableSound !== undefined)
166
+ next.android_disableSound = android_disableSound;
167
+ return next;
150
168
  });
151
169
 
152
170
  // See View.svelte's note on `{@attach}`.
@@ -1,5 +1,9 @@
1
1
  import type { IPressableProps } from './pressable-props';
2
2
  export type { IPressableProps };
3
- declare const Index: import("svelte").Component<IPressableProps, {}, "">;
3
+ type $$ComponentProps = IPressableProps & {
4
+ /** @internal Touchable* mirrors RN's Pressability minPressDuration: 0 override. */
5
+ __minPressDuration?: number;
6
+ };
7
+ declare const Index: import("svelte").Component<$$ComponentProps, {}, "">;
4
8
  type Index = ReturnType<typeof Index>;
5
9
  export default Index;
@@ -4,6 +4,7 @@ import type { IAccessibilityProps, IAriaProps } from '@symbiote-native/component
4
4
  import type { ISvelteClassValue } from '../class-value';
5
5
  export interface ISafeAreaViewProps extends IAccessibilityProps, IAriaProps {
6
6
  style?: IStyleProp<IViewStyle>;
7
+ id?: string;
7
8
  class?: ISvelteClassValue;
8
9
  onLayout?: (event: ISymbioteEvent) => void;
9
10
  children?: Snippet;
@@ -3,7 +3,7 @@
3
3
  // render-only). Reuses the shared logic verbatim (switchReducer / valueFromChange /
4
4
  // shouldSnapBack, exactly like React's useReducer + Vue's ref-based reducer wiring) and
5
5
  // calls renderSwitch() itself for the prop assembly — its Descriptor is always exactly one
6
- // `symbiote-switch` node with zero children (core/components/src/view/render-switch.ts). Root
6
+ // `symbiote-switch-managed` node with zero children (core/components/src/view/render-switch.ts). Root
7
7
  // stays a literal tag (bind:this needs a known tag); `createDescriptorChildrenSync` is still
8
8
  // wired for its (always-empty) children, matching every other category-1 component uniformly
9
9
  // — the same reason React still routes a childless Switch through descriptorToReact rather
@@ -131,4 +131,4 @@
131
131
  });
132
132
  </script>
133
133
 
134
- <symbiote-switch p={descriptor.props} bind:this={hostShim} />
134
+ <symbiote-switch-managed p={descriptor.props} bind:this={hostShim} />
@@ -48,6 +48,10 @@
48
48
  setInputFocused,
49
49
  setInputBlurred,
50
50
  type ISymbioteEvent,
51
+ type ISymbioteNode,
52
+ type IMeasureOnSuccess,
53
+ type IMeasureInWindowOnSuccess,
54
+ type IMeasureLayoutOnSuccess,
51
55
  } from '@symbiote-native/engine';
52
56
  import { createDescriptorChildrenSync } from '../../descriptor-to-svelte';
53
57
  import { createAttachmentsSync } from '../../runes/attachments';
@@ -277,6 +281,43 @@
277
281
  ]);
278
282
  }
279
283
 
284
+ // The four host-ref methods, FORWARDED to the engine node rather than reimplemented.
285
+ //
286
+ // Without them a TextInput ref was poorer than any other host ref in the adapter: every other
287
+ // primitive hands back a node carrying measure/measureInWindow/measureLayout/setNativeProps, and
288
+ // this wrapper closed the node behind five methods of its own. The lowered path lost the opposite
289
+ // four, so the two paths were not "one narrowing the other" but two different surfaces.
290
+ // `ITextInputHandle` is the union of both halves, and `text-input.smoke.test.ts` derives what this
291
+ // component must expose from `buildTextInputHandle` — so a method added to core fails here rather
292
+ // than going missing on whichever path an app happened to take.
293
+ //
294
+ // Not typed as `satisfies ITextInputHandle`: these are instance-script exports, so the component's
295
+ // imperative surface is structurally invisible to the compiler — which is exactly why the test is
296
+ // the guard here and the type is the guard on React, Angular and Solid.
297
+ export function measure(callback: IMeasureOnSuccess): void {
298
+ hostShim?.engineNode?.measure(callback);
299
+ }
300
+
301
+ export function measureInWindow(callback: IMeasureInWindowOnSuccess): void {
302
+ hostShim?.engineNode?.measureInWindow(callback);
303
+ }
304
+
305
+ export function measureLayout(
306
+ relativeToNativeNode: ISymbioteNode | number,
307
+ onSuccess: IMeasureLayoutOnSuccess,
308
+ onFail?: () => void,
309
+ ): void {
310
+ hostShim?.engineNode?.measureLayout(
311
+ relativeToNativeNode,
312
+ onSuccess,
313
+ onFail,
314
+ );
315
+ }
316
+
317
+ export function setNativeProps(nativeProps: Record<string, unknown>): void {
318
+ hostShim?.engineNode?.setNativeProps(nativeProps);
319
+ }
320
+
280
321
  const descriptor = $derived(
281
322
  renderTextInput({
282
323
  multiline: isMultiline,
@@ -309,8 +350,23 @@
309
350
  });
310
351
  </script>
311
352
 
353
+ <!--
354
+ The `-managed` tags, matching `renderTextInput`'s intrinsics. They resolve to the SAME native view
355
+ as the plain ones; the separate spelling is what keeps the host behavior off this node. The machine
356
+ is registered against `symbiote-text-input` / `…-multiline`, which only the LOWERING transform
357
+ emits, and this wrapper runs the same lifecycle itself (focus/blur mirror, event counter,
358
+ controlled write, autoFocus) — so sharing a tag would give one node two owners.
359
+
360
+ Written literally rather than as `<svelte:element this={descriptor.type}>` for the reason the
361
+ header records, which means this pair does NOT follow `render-text-input.ts` on its own: if those
362
+ intrinsics are renamed again, this file has to move with them. `text-input-tag.test.ts` is what
363
+ makes that a failing test instead of a silent double-owner.
364
+ -->
312
365
  {#if isMultiline}
313
- <symbiote-text-input-multiline p={descriptor.props} bind:this={hostShim} />
366
+ <symbiote-text-input-multiline-managed
367
+ p={descriptor.props}
368
+ bind:this={hostShim}
369
+ />
314
370
  {:else}
315
- <symbiote-text-input p={descriptor.props} bind:this={hostShim} />
371
+ <symbiote-text-input-managed p={descriptor.props} bind:this={hostShim} />
316
372
  {/if}
@@ -1,12 +1,17 @@
1
1
  import type { ITextInputProps } from './text-input-props';
2
2
  export type { ITextInputProps };
3
3
  export type { ITextInputHandle } from '@symbiote-native/components';
4
+ import { type ISymbioteNode, type IMeasureOnSuccess, type IMeasureInWindowOnSuccess, type IMeasureLayoutOnSuccess } from '@symbiote-native/engine';
4
5
  declare const Index: import("svelte").Component<ITextInputProps, {
5
6
  focus: () => void;
6
7
  blur: () => void;
7
8
  clear: () => void;
8
9
  isFocused: () => boolean;
9
10
  setSelection: (start: number, end: number) => void;
11
+ measure: (callback: IMeasureOnSuccess) => void;
12
+ measureInWindow: (callback: IMeasureInWindowOnSuccess) => void;
13
+ measureLayout: (relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void) => void;
14
+ setNativeProps: (nativeProps: Record<string, unknown>) => void;
10
15
  }, "value">;
11
16
  type Index = ReturnType<typeof Index>;
12
17
  export default Index;
@@ -126,6 +126,7 @@
126
126
  </script>
127
127
 
128
128
  <Pressable
129
+ __minPressDuration={0}
129
130
  {...rest}
130
131
  style={containerStyle}
131
132
  onPress={handlePress}
@@ -40,7 +40,7 @@
40
40
  const nativeProps = $derived(backgroundProps(resolved, useForeground));
41
41
  </script>
42
42
 
43
- <Pressable {...rest}>
43
+ <Pressable __minPressDuration={0} {...rest}>
44
44
  {#snippet children()}
45
45
  <symbiote-view p={nativeProps}>
46
46
  {@render content?.()}
@@ -168,6 +168,7 @@
168
168
  </script>
169
169
 
170
170
  <Pressable
171
+ __minPressDuration={0}
171
172
  {...rest}
172
173
  onPressIn={handlers.handlePressIn}
173
174
  onPressOut={handlers.handlePressOut}
@@ -64,6 +64,7 @@
64
64
  </script>
65
65
 
66
66
  <Pressable
67
+ __minPressDuration={0}
67
68
  {...rest}
68
69
  onPressIn={handlers.handlePressIn}
69
70
  onPressOut={handlers.handlePressOut}
@@ -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';
@@ -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;
@@ -22,9 +22,8 @@ function isShimElement(value) {
22
22
  }
23
23
  // The typed imperative handle (measure/measureInWindow/measureLayout/setNativeProps/focus/blur)
24
24
  // a `bind:this` host ref carries — the Svelte twin of a React `ref.current`/Vue template ref
25
- // already being an `IHostInstance`. `dom-shim/element.ts`'s `createEngineNode()` grafts
26
- // toPublicInstance onto every host node AT CREATION, so this call is idempotent (its own
27
- // 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
28
27
  // TYPED accessor off the SHIM value (`ShimElement`) instead of the bare `.engineNode` field,
29
28
  // with no `as` cast at the call site.
30
29
  export function hostInstance(shim) {
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';
package/build/index.js CHANGED
@@ -9,6 +9,10 @@
9
9
  // `descriptorToSvelte` bridge was ever needed — each component below is hand-authored Svelte
10
10
  // markup mirroring its render-*.ts, reusing only the pure state/render logic. See skill §17/§18
11
11
  // for the one open async-exception repro and the honest cross-cutting gap list.
12
+ // Bare side-effect import, deliberately NOT a re-export and deliberately not beside one of the
13
+ // same specifier: it registers the press behavior with the engine's host-behavior registry. Any
14
+ // other shape is dropped by Metro's inlineRequires in a release build — see register.ts.
15
+ import './register.js';
12
16
  export { mount, unmount } from './render.js';
13
17
  export { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
14
18
  export { View, Text, ActivityIndicator, Image, ImageBackground, InputAccessoryView, KeyboardAvoidingView, Switch, TextInput, Modal, SafeAreaView, RefreshControl, Pressable, TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, TouchableNativeFeedback, Button, ScrollView, ScrollViewStickyHeader, VirtualizedList, FlatList, VirtualizedSectionList, SectionList, } from './components/index.js';
@@ -0,0 +1,10 @@
1
+ interface IMarkupInput {
2
+ readonly content: string;
3
+ readonly filename?: string;
4
+ }
5
+ export declare function lowerHostPrimitives(): {
6
+ markup(input: IMarkupInput): {
7
+ code: string;
8
+ };
9
+ };
10
+ export {};