@symbiote-native/svelte 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,9 +29,9 @@ npm install @symbiote-native/svelte react-native svelte
29
29
 
30
30
  `react-native` and `svelte` stay your app's own top-level dependencies — this package only
31
31
  replaces the JS renderer that drives them. Every component is a `.svelte` SFC — there's no TSX
32
- alternative the way Vue offers — so the Metro wiring below isn't optional. There's no
33
- `create-symbiote` scaffolder yet, so it comes from [`examples/svelte`](../../examples/svelte)
34
- rather than a generator:
32
+ alternative the way Vue offers — so the Metro wiring below isn't optional. `npx @symbiote-native/cli new`
33
+ sets this up for a new app; wiring it into an existing one still follows
34
+ [`examples/svelte`](../../examples/svelte) rather than a generator:
35
35
 
36
36
  - `metro.config.js` — point `babelTransformerPath` at
37
37
  `@symbiote-native/svelte/metro-svelte-transformer` (compiles `.svelte` on the way into the
@@ -90,6 +90,10 @@
90
90
  };
91
91
  }
92
92
 
93
+ const rowKeyForRow = $derived(
94
+ (row: IRow<ItemT>): string => rowKeyExtractor(row, props.keyExtractor),
95
+ );
96
+
93
97
  const rowOnViewableItemsChanged = $derived.by(() => {
94
98
  const onChanged = props.onViewableItemsChanged;
95
99
  if (onChanged === undefined) return undefined;
@@ -251,7 +255,7 @@
251
255
  header={props.header}
252
256
  footer={props.footer}
253
257
  empty={props.empty}
254
- keyExtractor={rowKeyExtractor}
258
+ keyExtractor={rowKeyForRow}
255
259
  getItemLayout={props.getItemLayout}
256
260
  horizontal={props.horizontal}
257
261
  inverted={props.inverted}
@@ -30,7 +30,9 @@
30
30
  import type { ShimElement } from '../../dom-shim';
31
31
  import {
32
32
  computeInset,
33
+ configureKeyboardAvoidingAnimation,
33
34
  keyboardAvoidingEventNamesFor,
35
+ readKeyboardAnimationTiming,
34
36
  readPrefersCrossFadeTransitions,
35
37
  readKeyboardFrame,
36
38
  readLayoutFrame,
@@ -68,11 +70,18 @@
68
70
  // was built: Svelte compiles a destructured prop into a live getter, so a `behavior` changed
69
71
  // after mount reaches the math on the next event, and 'height' mode feeds back the inset
70
72
  // CURRENTLY applied (RN's this.state.bottom) to cancel the shrink its own wrapper caused.
73
+ const previousInset = inset;
71
74
  const next = computeInset(frame, keyboard, keyboardVerticalOffset, {
72
75
  behavior,
73
- previousInset: inset,
76
+ previousInset,
74
77
  prefersCrossFadeTransitions,
75
78
  });
79
+ // RN's `_updateBottomIfNecessary` skips the animation when the inset did not change.
80
+ if (next !== previousInset)
81
+ configureKeyboardAvoidingAnimation(
82
+ readKeyboardAnimationTiming(payload),
83
+ enabled,
84
+ );
76
85
  dlog(`KeyboardAvoidingView show -> inset ${next}`);
77
86
  inset = next;
78
87
  }
@@ -13,7 +13,9 @@ export interface IPressableProps extends IAccessibilityProps, IAriaProps {
13
13
  disabled?: boolean;
14
14
  focusable?: boolean;
15
15
  cancelable?: boolean;
16
+ blockNativeResponder?: boolean;
16
17
  hitSlop?: IRectOffset;
18
+ testOnly_pressed?: boolean;
17
19
  pressRetentionOffset?: IRectOffset;
18
20
  unstable_pressDelay?: number;
19
21
  android_ripple?: IPressableAndroidRippleConfig;
@@ -2,6 +2,8 @@ import type { ITouchableBaseProps } from '../touchable-base-props';
2
2
  export interface ITouchableHighlightProps extends ITouchableBaseProps {
3
3
  activeOpacity?: number;
4
4
  underlayColor?: string;
5
+ touchSoundDisabled?: boolean;
6
+ testOnly_pressed?: boolean;
5
7
  onShowUnderlay?: () => void;
6
8
  onHideUnderlay?: () => void;
7
9
  }
@@ -5,4 +5,5 @@ export type ITouchableNativeFeedbackProps = Omit<IPressableProps, 'style' | 'chi
5
5
  background?: INativeFeedbackBackground;
6
6
  useForeground?: boolean;
7
7
  children?: Snippet;
8
+ touchSoundDisabled?: boolean;
8
9
  };
@@ -1,2 +1,4 @@
1
1
  import type { ITouchableBaseProps } from '../touchable-base-props';
2
- export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;
2
+ export type ITouchableWithoutFeedbackProps = ITouchableBaseProps & {
3
+ touchSoundDisabled?: boolean;
4
+ };
@@ -40,10 +40,8 @@
40
40
 
41
41
  <script lang="ts" generics="ItemT">
42
42
  import {
43
- DEFAULT_END_REACHED_THRESHOLD,
44
43
  DEFAULT_INITIAL_NUM_TO_RENDER,
45
44
  DEFAULT_MAX_TO_RENDER_PER_BATCH,
46
- DEFAULT_START_REACHED_THRESHOLD,
47
45
  DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
48
46
  DEFAULT_WINDOW_SIZE,
49
47
  EMPTY_OFFSET,
@@ -128,11 +126,9 @@
128
126
  horizontal: props.horizontal === true,
129
127
  inverted: props.inverted === true,
130
128
  onEndReached: props.onEndReached,
131
- onEndReachedThreshold:
132
- props.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD,
129
+ onEndReachedThreshold: props.onEndReachedThreshold,
133
130
  onStartReached: props.onStartReached,
134
- onStartReachedThreshold:
135
- props.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD,
131
+ onStartReachedThreshold: props.onStartReachedThreshold,
136
132
  onRefresh: props.onRefresh,
137
133
  refreshing: props.refreshing,
138
134
  progressViewOffset: props.progressViewOffset,
@@ -252,7 +248,12 @@
252
248
  const info = effect.info;
253
249
  const map = effect.map;
254
250
  const fire = (): void => {
255
- for (const pair of pairs) pair.onViewableItemsChanged(info);
251
+ for (const pair of pairs) {
252
+ pair.onViewableItemsChanged({
253
+ ...info,
254
+ viewabilityConfig: pair.viewabilityConfig,
255
+ });
256
+ }
256
257
  dispatch({ kind: 'viewable-fired', map });
257
258
  };
258
259
  if (viewableTimer !== null) {
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { IStyleProp, ISymbioteEvent, ISymbioteNode, IViewStyle } from '@symbiote-native/engine';
2
+ import { type IStyleProp, type ISymbioteEvent, type ISymbioteNode, type IViewStyle } from '@symbiote-native/engine';
3
3
  import { type IAccessibilityProps, type IAriaProps, type ISeparatorProps, type ISeparators, type IViewabilityConfig, type IViewabilityConfigCallbackPair, type IViewableItemsChangedInfo, type IVirtualizedListHandle } from '@symbiote-native/components';
4
4
  import type { ISvelteClassValue } from '../../class-value';
5
5
  export type { IVirtualizedListHandle };
@@ -67,4 +67,4 @@ export interface IVirtualizedListProps<ItemT> extends IAccessibilityProps, IAria
67
67
  class?: ISvelteClassValue;
68
68
  }
69
69
  export type { ISeparators, ISeparatorProps, ISymbioteEvent, ISymbioteNode };
70
- export declare function pickAccessibilityProps<T extends IAccessibilityProps & IAriaProps>(props: T): IAccessibilityProps;
70
+ export declare function pickAccessibilityProps<T extends IAccessibilityProps & IAriaProps>(props: T): IAccessibilityProps & IAriaProps;
@@ -1,14 +1,18 @@
1
- import { resolveAccessibilityProps, } from '@symbiote-native/components';
1
+ import { ARIA_ALIAS_KEYS, } from '@symbiote-native/engine';
2
+ import {} from '@symbiote-native/components';
2
3
  // Every IAccessibilityProps field, named explicitly (the object-bag convention this adapter's
3
4
  // custom-element host tags require — svelte-adapter-dom-shim skill §3g(c) — forbids a raw
4
5
  // `...rest` spread landing on a symbiote-* tag, so this mirrors React's `...accessibilityRest`
5
6
  // field-by-field instead of reusing its spread). Shared by every list component (VirtualizedList's
6
7
  // own host-bag construction AND FlatList's/VirtualizedSectionList's/SectionList's component-to-
7
8
  // component forwarding down to VirtualizedList) so the field list lives in exactly one place.
8
- // resolveAccessibilityProps folds aria-*/role into their accessibility* twins first (idempotent —
9
- // calling it twice, once per forwarding hop, is a documented no-op once the aria keys are gone).
9
+ // It used to call `resolveAccessibilityProps` first, folding `aria-*`/`role` into their
10
+ // `accessibility*` twins so there were canonical names to pick BY NAME. That fold is the device's
11
+ // rule now, so the aria keys are picked and forwarded RAW instead (the loop at the end) and the
12
+ // engine folds once, at the leaf. Picking twice across a forwarding hop stays a no-op for the same
13
+ // reason folding twice was: a pick is idempotent.
10
14
  export function pickAccessibilityProps(props) {
11
- const resolved = resolveAccessibilityProps(props);
15
+ const resolved = props;
12
16
  const picked = {};
13
17
  if (resolved.testID !== undefined)
14
18
  picked.testID = resolved.testID;
@@ -73,5 +77,21 @@ export function pickAccessibilityProps(props) {
73
77
  picked.onMagicTap = resolved.onMagicTap;
74
78
  if (resolved.onAccessibilityEscape !== undefined)
75
79
  picked.onAccessibilityEscape = resolved.onAccessibilityEscape;
80
+ // THE ARIA HALF, forwarded RAW and DERIVED from the engine's own list rather than named again
81
+ // here. `ARIA_ALIAS_KEYS` is exported for exactly this — its comment says a second hand-written
82
+ // copy is what `adapter-parity-audit.md` records going stale one member at a time — so this loop
83
+ // gains a new alias the day the engine does.
84
+ //
85
+ // Raw, because the fold is the device's rule (`foldAriaProps`, `SymbioteFabricProps.cpp`) and it
86
+ // reads these names literally off the bag it commits. Folding them here would be a second
87
+ // implementation of it, and the one this file used to hold: `resolveAccessibilityProps`.
88
+ for (const key of ARIA_ALIAS_KEYS) {
89
+ const value = props[key];
90
+ // `Object.assign` with a computed key, not `picked[key] = value`: the key is a union and the
91
+ // value is its correlated member type, which TypeScript cannot check across a loop. This is the
92
+ // spelling that stays sound without an `as`.
93
+ if (value !== undefined)
94
+ Object.assign(picked, { [key]: value });
95
+ }
76
96
  return picked;
77
97
  }
@@ -55,7 +55,7 @@
55
55
  // Node identity | nodes are MOVED (the old | content is re-created per TunnelOut
56
56
  // | parent empties) |
57
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';
58
+ import { appendChild as engineAppendChild, removeChild as engineRemoveChild, componentOf, dlog, isSymbioteNode, SymbioteSurface, } from '@symbiote-native/engine';
59
59
  import { ShimComment, ShimElement, ShimNode } from '../dom-shim/index.js';
60
60
  // The Svelte flavour of React's `isSymbioteNode` guard, and it needs its own wording: React tells
61
61
  // the caller to check for a forgotten `.current`, which does not exist here. A Svelte host ref is
@@ -75,7 +75,7 @@ function targetLabel(target) {
75
75
  return `surface#${target.rootTag}`;
76
76
  if (target instanceof ShimElement)
77
77
  return target.tagName;
78
- return target.component;
78
+ return componentOf(target);
79
79
  }
80
80
  // Attach the fragment host under `target` and hand back the matching detach. Three branches
81
81
  // because the three target kinds sit at different layers:
@@ -79,6 +79,7 @@ export const CANONICAL_PROP_NAMES = [
79
79
  'backgroundColor',
80
80
  'barStyle',
81
81
  'behavior',
82
+ 'blockNativeResponder',
82
83
  'blurOnSubmit',
83
84
  'blurRadius',
84
85
  'bounces',
@@ -178,6 +179,7 @@ export const CANONICAL_PROP_NAMES = [
178
179
  'onAccessibilityEscape',
179
180
  'onAccessibilityTap',
180
181
  'onBlur',
182
+ 'onChangeText',
181
183
  'onContentSizeChange',
182
184
  'onDismiss',
183
185
  'onEndEditing',
@@ -290,6 +292,7 @@ export const CANONICAL_PROP_NAMES = [
290
292
  'submitBehavior',
291
293
  'supportedOrientations',
292
294
  'testID',
295
+ 'testOnly_pressed',
293
296
  'textAlign',
294
297
  'textContentType',
295
298
  'thumbColor',
@@ -10,6 +10,7 @@ export declare class ShimElement extends ShimElementBase {
10
10
  private doorBag;
11
11
  private pBag;
12
12
  private lastBag;
13
+ private ownsDoorBag;
13
14
  constructor(tagName: string, namespaceURI?: string);
14
15
  get nodeName(): string;
15
16
  private styleSlot;
@@ -26,7 +26,6 @@ import { createElement, dlog, routeProp, toPublicInstance, } from '@symbiote-nat
26
26
  import { descriptorFor } from '@symbiote-native/components';
27
27
  import { normalizeSvelteClass } from '../class-value.js';
28
28
  import { CANONICAL_BY_LOWER, CANONICAL_PROP_NAMES, } from './canonical-prop-names.js';
29
- import { foldHostBag } from './fold-host-bag.js';
30
29
  import { ShimNode } from './shim-node.js';
31
30
  import { discoverStyleCacheKey } from './style-cache.js';
32
31
  // The bag a not-yet-live element is diffed against in onMadeLive. A module constant rather than a
@@ -61,11 +60,19 @@ export class ShimElement extends ShimElementBase {
61
60
  // The four attribute doors write `doorBag`, `set p` writes `pBag`, `lastBag` is the folded merge.
62
61
  // Held in ONE object, `set p` deleted the `class` `from_tree` had written at clone time, so
63
62
  // `<view p={handlers} class="x">` committed with no style at all and nothing was red. Unreachable
64
- // while an element used one door, which every lowered tag and adapter component does.
63
+ // while an element used one door, which nearly every element does.
65
64
  // Both LAZY: an element touching one door still allocates one object.
66
65
  doorBag = undefined;
67
66
  pBag = undefined;
68
67
  lastBag = EMPTY_BAG;
68
+ // COPY-ON-WRITE ownership of `doorBag`. `true` only while THIS element is the sole holder of the
69
+ // object `doorBag` points at, which lets `writeBagKey` mutate it in place instead of re-copying it
70
+ // on every one of an element's own sequential door writes. `cloneNode` hands the SAME object to a
71
+ // second element (§ below) and must clear this on both sides the moment it does, or a write on
72
+ // one clone would mutate the bag a sibling clone still reads — see
73
+ // `prop-bag-diff.test.ts`'s "a write to one clone does not leak into a sibling clone" for the
74
+ // isolation this exists to preserve.
75
+ ownsDoorBag = false;
69
76
  constructor(tagName, namespaceURI) {
70
77
  super();
71
78
  this.tagName = tagName;
@@ -78,7 +85,7 @@ export class ShimElement extends ShimElementBase {
78
85
  // no-opping — a `style` attribute on a bare tag crashed the mount. LAZY, for the reason
79
86
  // `.claude/rules/svelte-shim-is-the-per-node-create-path.md` records about the two Maps below it:
80
87
  // an eager field is one object per element, ~9 000 per create, in the window where GC is the
81
- // largest bucket. Only `set_style` touches this, and no lowered element takes that path.
88
+ // largest bucket. Only `set_style` touches this, and a bag-carrying element never takes it.
82
89
  styleSlot = undefined;
83
90
  get style() {
84
91
  return (this.styleSlot ??= { cssText: '' });
@@ -120,12 +127,10 @@ export class ShimElement extends ShimElementBase {
120
127
  applyBagDiff(this.engineNode, prev, next);
121
128
  this.surface?.requestCommit();
122
129
  }
123
- // A BARE tag's props arrive here, one key at a time, and they used to stop here: an inert Map,
124
- // nothing routed, nothing committed, nothing red. That was survivable only while every host
125
- // element in an app was produced by the lowering transform, which builds the `p` bag above —
126
- // and it is exactly what made that transform load-bearing for CORRECTNESS on this adapter
127
- // alone. Routing the key makes `<view testID="x">` and `<view p={{ testID: 'x' }}>` the same
128
- // commit, so the transform goes back to being the optimisation it is everywhere else.
130
+ // A tag's props arrive here one key at a time, and they used to stop here: an inert Map, nothing
131
+ // routed, nothing committed, nothing red. Routing the key is what makes `<view testID="x">` and
132
+ // `<view p={{ testID: 'x' }}>` the same commit — the bag spelling is an optimisation, not the
133
+ // only way to reach the engine.
129
134
  //
130
135
  // `value` is `unknown`, not `string`: Svelte's `set_attribute` hands the raw value straight
131
136
  // through for a name with no prototype setter, so an object `style` or a number arrives
@@ -156,7 +161,17 @@ export class ShimElement extends ShimElementBase {
156
161
  // Not `private`: the prototype accessors installed at the bottom of this file are the third
157
162
  // writer, and they are defined from module scope because there are ~290 of them.
158
163
  writeBagKey(name, value) {
159
- const door = { ...this.doorBag };
164
+ // COW: reuse `doorBag` in place only while this element is its SOLE owner (never cloned since
165
+ // its last write) — `cloneNode` clears the flag on both sides the instant a bag becomes shared
166
+ // (§ above). A fresh `{}` is `own`ed immediately: nothing else can reach it yet.
167
+ // Narrowed through a LOCAL, not through `owned`: TypeScript discards an aliased condition
168
+ // whose operands include a mutable property (`this.ownsDoorBag`), so the `!== undefined` leg
169
+ // never reached `this.doorBag` at the use site and this line did not compile.
170
+ const reusable = this.doorBag !== undefined && this.ownsDoorBag ? this.doorBag : undefined;
171
+ const owned = reusable !== undefined;
172
+ const door = reusable ?? { ...this.doorBag };
173
+ this.ownsDoorBag = true;
174
+ const prevValue = door[name];
160
175
  if (value === undefined)
161
176
  delete door[name];
162
177
  else
@@ -167,6 +182,21 @@ export class ShimElement extends ShimElementBase {
167
182
  this.lastBag = next;
168
183
  if (this.engineNode === undefined)
169
184
  return; // not live yet — onMadeLive() replays in full
185
+ // `foldedBag()` hands `door` straight back, BY REFERENCE, whenever there is no `p` bag to merge
186
+ // it with (its own "no `pBag`" branch) — so when `door` was mutated IN PLACE above (`owned`),
187
+ // `next` and `door` are the same object post-mutation, and a full `prev`-vs-`next` diff would
188
+ // be comparing that object against itself and finding nothing changed. The one key just written
189
+ // is the whole diff in that shape, by construction, so route it directly and skip the diff loop
190
+ // — this is what makes the COW above safe rather than merely silent (see
191
+ // `prop-bag-diff.test.ts`'s "commits an attribute written after it is live" style cases, which
192
+ // caught the alternative — a no-op write — the first time this was tried without this branch).
193
+ if (owned && this.pBag === undefined) {
194
+ const nextValue = next[name];
195
+ if (prevValue !== nextValue)
196
+ routeProp(this.engineNode, name, nextValue);
197
+ this.surface?.requestCommit();
198
+ return;
199
+ }
170
200
  applyBagDiff(this.engineNode, prev, next);
171
201
  this.surface?.requestCommit();
172
202
  }
@@ -176,10 +206,10 @@ export class ShimElement extends ShimElementBase {
176
206
  const door = this.doorBag;
177
207
  const bag = this.pBag;
178
208
  if (door === undefined)
179
- return foldHostBag(this.tagName, normalizeBagClasses(bag ?? {}));
209
+ return normalizeBagClasses(bag ?? {});
180
210
  if (bag === undefined)
181
- return foldHostBag(this.tagName, normalizeBagClasses(door));
182
- return foldHostBag(this.tagName, normalizeBagClasses({ ...bag, ...door }));
211
+ return normalizeBagClasses(door);
212
+ return normalizeBagClasses({ ...bag, ...door });
183
213
  }
184
214
  // THE FIFTH DOOR, and it converges on the same bag as the other four. Svelte turns EVERY
185
215
  // `on<Name>` attribute into `$.event()`, whatever the name means to us — so this receives both
@@ -207,6 +237,12 @@ export class ShimElement extends ShimElementBase {
207
237
  clone.doorBag = this.doorBag;
208
238
  clone.pBag = this.pBag;
209
239
  clone.lastBag = this.lastBag;
240
+ // `doorBag` (if any) now has TWO owners. Neither may mutate it in place from here — the next
241
+ // write on either side must copy first and only then reclaims exclusive ownership of ITS OWN
242
+ // copy. Without this a clone's first `setAttribute` would mutate the master's (and every other
243
+ // sibling clone's) committed props in place.
244
+ this.ownsDoorBag = false;
245
+ clone.ownsDoorBag = false;
210
246
  if (deep === true) {
211
247
  for (const child of this.children)
212
248
  clone.appendChild(child.cloneNode(true));
@@ -72,8 +72,8 @@ const FAKE_CUSTOM_ELEMENT_REGISTRY = {
72
72
  // rather than by reading. Svelte's `event()` (dom/elements/events.js) ends its teardown check with
73
73
  // a BARE `dom instanceof HTMLMediaElement`, no `typeof` guard, so an undeclared global throws
74
74
  // ReferenceError instead of yielding `undefined`. It is reached by every `on<Name>={fn}` an app
75
- // writes on a bare tag, which is why nothing hit it while the lowering transform funnelled every
76
- // handler through the `p` bag. Nothing of ours ever derives from it, so an empty class is enough:
75
+ // writes on a bare tag, which is why nothing hit it while every handler went through the `p` bag.
76
+ // Nothing of ours ever derives from it, so an empty class is enough:
77
77
  // `instanceof` only has to answer false.
78
78
  class FakeHTMLMediaElement {
79
79
  }
@@ -3,6 +3,10 @@ type IDocumentLike = {
3
3
  createTextNode(value: string): ShimNode;
4
4
  };
5
5
  export declare function registerShimDocumentFactory(factory: () => IDocumentLike): void;
6
+ export declare function takeShimScanStats(): {
7
+ calls: number;
8
+ scanned: number;
9
+ };
6
10
  export declare abstract class ShimNode {
7
11
  parent: ShimNode | null;
8
12
  children: ShimNode[];
@@ -25,6 +29,8 @@ export declare abstract class ShimNode {
25
29
  remove(): void;
26
30
  removeChild<T extends ShimNode>(child: T): T;
27
31
  set textContent(value: string);
32
+ private indexNearEnd;
33
+ private scannedIndexOf;
28
34
  private insertOne;
29
35
  }
30
36
  export {};
@@ -25,6 +25,31 @@ function shimDocument() {
25
25
  }
26
26
  return documentFactory();
27
27
  }
28
+ // How many insertBefore ref lookups fell through `indexNearEnd`'s bounded window into the full,
29
+ // unbounded `children.indexOf` scan, and how much of the array each one walked. Read-and-zeroed
30
+ // through `takeShimScanStats`, same shape as `core/engine/src/node.ts`'s `propStats`. This is the
31
+ // JS-side twin of a question F-80 already answered for the engine's own `OP_INSERT_BEFORE`
32
+ // (`std::find` over `siblings`, exonerated as too small): before `indexNearEnd` existed, Svelte's
33
+ // OWN reconciler paid the identical linear scan a second time, one layer up, on every keyed
34
+ // `{#each}` append — measured at 1.5M comparisons for a 1 000-row Append onto 1 000 standing
35
+ // (`each-append-scan-cost.probe.test.ts`). A zero reading here now means every ref this run
36
+ // needed was found in the tail window; a nonzero one means something inserts against a ref
37
+ // further from the end than `TAIL_SCAN_WINDOW` covers, which is real work this counter still
38
+ // prices honestly rather than hiding behind the fast path.
39
+ const shimScanStats = { calls: 0, scanned: 0 };
40
+ // Covers the measured case (a trailing block/component boundary anchor, one small constant hop
41
+ // from the true end) with room to spare, without making the fallback-miss case pay a meaningfully
42
+ // bigger constant before it gives up and scans properly.
43
+ const TAIL_SCAN_WINDOW = 8;
44
+ export function takeShimScanStats() {
45
+ const snapshot = {
46
+ calls: shimScanStats.calls,
47
+ scanned: shimScanStats.scanned,
48
+ };
49
+ shimScanStats.calls = 0;
50
+ shimScanStats.scanned = 0;
51
+ return snapshot;
52
+ }
28
53
  export class ShimNode {
29
54
  parent = null;
30
55
  children = [];
@@ -131,6 +156,28 @@ export class ShimNode {
131
156
  this.appendChild(shimDocument().createTextNode(value));
132
157
  }
133
158
  }
159
+ // Bounded backward scan, O(TAIL_SCAN_WINDOW) not O(children.length). Covers the case that
160
+ // dominates a real Append: a keyed `{#each}` growing at the tail inserts every new row via
161
+ // `newRow.before(ref)` where `ref` is the block's own closing boundary anchor — measured
162
+ // (`each-append-scan-cost.probe.test.ts`) sitting a SMALL, constant distance from the true end
163
+ // (one extra trailing anchor from the component's own root fragment in the measured case, not
164
+ // necessarily always exactly one — hence a window, not a hardcoded offset). Returns `undefined`
165
+ // on a miss so the caller falls back to the full, always-correct scan; this never changes the
166
+ // result, only how cheaply the common case finds it.
167
+ indexNearEnd(ref) {
168
+ const { children } = this;
169
+ const start = Math.max(0, children.length - TAIL_SCAN_WINDOW);
170
+ for (let i = children.length - 1; i >= start; i -= 1) {
171
+ if (children[i] === ref)
172
+ return i;
173
+ }
174
+ return undefined;
175
+ }
176
+ scannedIndexOf(ref) {
177
+ shimScanStats.calls += 1;
178
+ shimScanStats.scanned += this.children.length;
179
+ return this.children.indexOf(ref);
180
+ }
134
181
  insertOne(node, ref) {
135
182
  detachFromParent(node);
136
183
  node.parent = this;
@@ -138,7 +185,7 @@ export class ShimNode {
138
185
  this.children.push(node);
139
186
  }
140
187
  else {
141
- const index = this.children.indexOf(ref);
188
+ const index = this.indexNearEnd(ref) ?? this.scannedIndexOf(ref);
142
189
  this.children.splice(index < 0 ? this.children.length : index, 0, node);
143
190
  }
144
191
  if (this.engineNode !== undefined && this.surface !== undefined) {
@@ -16,7 +16,7 @@
16
16
  // Fabric (the reason Vue's renderer maps it to an anchor rather than an empty text); and a raw
17
17
  // text child of a non-Text parent is the invalid "text outside <Text>" shape. Found by
18
18
  // native-node-parity.test.ts, which diffs committed native trees against the Vue adapter.
19
- import { appendChild as engineAppendChild, createAnchor, createRawText, insertBefore as engineInsertBefore, isAnchor, removeChild as engineRemoveChild, setText, } from '@symbiote-native/engine';
19
+ import { isTextContainer, 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
21
  // Deliberately WIDER than Svelte's own whitespace class. `svelte/src/compiler/phases/patterns.js`
22
22
  // uses /[^ \t\r\n]/ and says why: "Not \S because that also removes explicit whitespace defined
@@ -49,7 +49,8 @@ const WHITESPACE_ONLY = /^[\s\u200b-\u200d\ufeff]+$/;
49
49
  function isFormattingWhitespace(value, parent) {
50
50
  if (!WHITESPACE_ONLY.test(value))
51
51
  return false;
52
- return parent?.engineNode?.isText !== true;
52
+ const engineNode = parent?.engineNode;
53
+ return engineNode === undefined || !isTextContainer(engineNode);
53
54
  }
54
55
  export class ShimText extends ShimNode {
55
56
  value;
@@ -9,7 +9,7 @@
9
9
  // RN's own findNodeHandle has). The node -> tag resolution itself belongs to the engine
10
10
  // (getNativeTag, keyed on the raw node in the commit mirror); this only adapts the
11
11
  // Svelte-shaped input (a ShimElement) onto it.
12
- import { getNativeTag, isSymbioteNode, toPublicInstance, dlog, } from '@symbiote-native/engine';
12
+ import { componentOf, getNativeTag, isSymbioteNode, toPublicInstance, dlog, } from '@symbiote-native/engine';
13
13
  // `engineNode` alone does NOT identify a ShimElement: it is declared on the shared ShimNode base
14
14
  // (dom-shim/shim-node.ts), so a ShimText / ShimComment / ShimDocumentFragment satisfies it too,
15
15
  // and none of those is a host ref an interop library can hand back. `tagName` is ShimElement's
@@ -50,7 +50,7 @@ export function findNodeHandle(componentOrHandle) {
50
50
  const node = componentOrHandle.engineNode;
51
51
  if (node !== undefined && isSymbioteNode(node)) {
52
52
  const tag = getNativeTag(node) ?? null;
53
- dlog(`findNodeHandle: component=${node.component} tag=${tag}`);
53
+ dlog(`findNodeHandle: component=${componentOf(node)} tag=${tag}`);
54
54
  return tag;
55
55
  }
56
56
  dlog('findNodeHandle: hostShim not live yet (engineNode undefined)');
@@ -3,25 +3,7 @@ declare const drivers: {
3
3
  Value: typeof AnimatedValue;
4
4
  ValueXY: typeof AnimatedValueXY;
5
5
  Color: typeof AnimatedColor;
6
- Easing: {
7
- step0(n: number): number;
8
- step1(n: number): number;
9
- linear(t: number): number;
10
- ease(t: number): number;
11
- quad(t: number): number;
12
- cubic(t: number): number;
13
- poly(n: number): import("@symbiote-native/engine").IEasingFunction;
14
- sin(t: number): number;
15
- circle(t: number): number;
16
- exp(t: number): number;
17
- elastic(bounciness?: number): import("@symbiote-native/engine").IEasingFunction;
18
- back(s?: number): import("@symbiote-native/engine").IEasingFunction;
19
- bounce(t: number): number;
20
- bezier(x1: number, y1: number, x2: number, y2: number): import("@symbiote-native/engine").IEasingFunction;
21
- in(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
22
- out(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
23
- inOut(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
24
- };
6
+ Easing: import("@symbiote-native/engine").IEasing;
25
7
  timing: typeof timing;
26
8
  spring: typeof spring;
27
9
  decay: typeof decay;
@@ -39,6 +21,28 @@ declare const drivers: {
39
21
  event: typeof event;
40
22
  forkEvent: typeof forkEvent;
41
23
  unforkEvent: typeof unforkEvent;
24
+ } | {
25
+ Value: typeof AnimatedValue;
26
+ ValueXY: typeof AnimatedValueXY;
27
+ Color: typeof AnimatedColor;
28
+ Easing: import("@symbiote-native/engine").IEasing;
29
+ timing: (value: AnimatedValue, config: import("@symbiote-native/engine").ITimingConfig) => import("@symbiote-native/engine").ICompositeAnimation;
30
+ spring: (value: AnimatedValue, config: import("@symbiote-native/engine").ISpringConfig) => import("@symbiote-native/engine").ICompositeAnimation;
31
+ decay: (_value: AnimatedValue, _config: import("@symbiote-native/engine").IDecayConfig) => import("@symbiote-native/engine").ICompositeAnimation;
32
+ parallel: (animations: import("@symbiote-native/engine").ICompositeAnimation[], _config?: import("@symbiote-native/engine").IParallelConfig) => import("@symbiote-native/engine").ICompositeAnimation;
33
+ sequence: (animations: import("@symbiote-native/engine").ICompositeAnimation[]) => import("@symbiote-native/engine").ICompositeAnimation;
34
+ stagger: (_time: number, animations: import("@symbiote-native/engine").ICompositeAnimation[]) => import("@symbiote-native/engine").ICompositeAnimation;
35
+ loop: (_animation: import("@symbiote-native/engine").ICompositeAnimation) => import("@symbiote-native/engine").ICompositeAnimation;
36
+ delay: (_time: number) => import("@symbiote-native/engine").ICompositeAnimation;
37
+ add: typeof add;
38
+ subtract: typeof subtract;
39
+ multiply: typeof multiply;
40
+ divide: typeof divide;
41
+ modulo: typeof modulo;
42
+ diffClamp: typeof diffClamp;
43
+ event: typeof event;
44
+ forkEvent: typeof forkEvent;
45
+ unforkEvent: typeof unforkEvent;
42
46
  };
43
47
  interface IAnimatedNamespace {
44
48
  FlatList: typeof import('../../components/flat-list/index.svelte').default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/svelte",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "SymbioteNative's Svelte adapter — a DOM shim over stock compiled Svelte output driving real native iOS/Android views through the same engine as every other adapter.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -73,22 +73,22 @@
73
73
  },
74
74
  "dependencies": {
75
75
  "typescript": "~6.0.0",
76
- "@symbiote-native/components": "2.0.0",
76
+ "@symbiote-native/components": "3.0.1",
77
77
  "@symbiote-native/css-parser": "0.5.0"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "svelte": ">=5.56.0",
81
81
  "react-native": ">=0.86",
82
- "@symbiote-native/engine": "^0.5.0"
82
+ "@symbiote-native/engine": "^1.2.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@vue/runtime-core": "^3.5.13",
86
86
  "react": "19.2.3",
87
87
  "svelte": "^5.56.0",
88
- "@symbiote-native/engine": "0.5.0",
89
- "@symbiote-native/react": "2.0.0",
90
- "@symbiote-native/test-utils": "0.3.0",
91
- "@symbiote-native/vue": "2.0.0"
88
+ "@symbiote-native/engine": "1.2.0",
89
+ "@symbiote-native/react": "3.0.1",
90
+ "@symbiote-native/test-utils": "0.4.1",
91
+ "@symbiote-native/vue": "3.0.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --build",
@@ -1 +0,0 @@
1
- export { foldHostBag, FOLD_PLAN_BY_TAG as BY_TAG, type IHostBag, } from '@symbiote-native/components/fold-host-bag';
@@ -1,7 +0,0 @@
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';