@symbiote-native/svelte 1.0.0 → 3.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 (117) hide show
  1. package/README.md +11 -13
  2. package/build/components/{activity-indicator/activity-indicator-props.d.ts → activity-indicator-props.d.ts} +1 -1
  3. package/build/components/flat-list/index.svelte +9 -5
  4. package/build/components/{image-background/image-background-props.d.ts → image-background-props.d.ts} +1 -1
  5. package/build/components/index.d.ts +6 -24
  6. package/build/components/index.js +0 -18
  7. package/build/components/keyboard-avoiding-view/index.svelte +16 -7
  8. package/build/components/modal/index.svelte +7 -7
  9. package/build/components/pressable/pressable-props.d.ts +3 -0
  10. package/build/components/refresh-control-props.d.ts +1 -0
  11. package/build/components/text-props.d.ts +1 -0
  12. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  13. package/build/components/touchable-native-feedback/touchable-native-feedback-props.d.ts +1 -0
  14. package/build/components/touchable-without-feedback/touchable-without-feedback-props.d.ts +3 -1
  15. package/build/components/virtualized-list/index.svelte +79 -257
  16. package/build/components/virtualized-list/virtualized-list-props.d.ts +2 -2
  17. package/build/components/virtualized-list/virtualized-list-props.js +24 -4
  18. package/build/create-portal/index.js +2 -2
  19. package/build/dom-shim/canonical-prop-names.d.ts +2 -0
  20. package/build/dom-shim/canonical-prop-names.js +323 -0
  21. package/build/dom-shim/document.js +1 -1
  22. package/build/dom-shim/element.d.ts +10 -3
  23. package/build/dom-shim/element.js +233 -31
  24. package/build/dom-shim/patch-globals.js +31 -5
  25. package/build/dom-shim/shim-node.d.ts +6 -0
  26. package/build/dom-shim/shim-node.js +48 -1
  27. package/build/dom-shim/style-cache.d.ts +1 -0
  28. package/build/dom-shim/style-cache.js +27 -0
  29. package/build/dom-shim/text.js +5 -4
  30. package/build/host-instance.d.ts +2 -3
  31. package/build/host-instance.js +9 -3
  32. package/build/index.d.ts +7 -4
  33. package/build/index.js +17 -5
  34. package/build/intrinsic-elements.d.ts +63 -0
  35. package/build/modules/animated/index.d.ts +30 -46
  36. package/build/modules/animated/index.js +24 -34
  37. package/build/modules/image/index.d.ts +3 -0
  38. package/build/modules/image/index.js +15 -0
  39. package/build/preprocessor/scoped-styles.js +1 -1
  40. package/build/register.d.ts +1 -1
  41. package/build/register.js +13 -31
  42. package/build/root-element.js +1 -1
  43. package/metro-css-parser.cjs +2 -1
  44. package/metro-svelte-transformer.cjs +1 -17
  45. package/package.json +8 -16
  46. package/build/components/RefreshControl.svelte +0 -40
  47. package/build/components/RefreshControl.svelte.d.ts +0 -5
  48. package/build/components/SafeAreaView.svelte +0 -34
  49. package/build/components/SafeAreaView.svelte.d.ts +0 -5
  50. package/build/components/Text.svelte +0 -34
  51. package/build/components/Text.svelte.d.ts +0 -5
  52. package/build/components/View.svelte +0 -43
  53. package/build/components/View.svelte.d.ts +0 -5
  54. package/build/components/activity-indicator/activity-indicator-platform.android.d.ts +0 -3
  55. package/build/components/activity-indicator/activity-indicator-platform.android.js +0 -5
  56. package/build/components/activity-indicator/activity-indicator-platform.d.ts +0 -1
  57. package/build/components/activity-indicator/activity-indicator-platform.ios.d.ts +0 -3
  58. package/build/components/activity-indicator/activity-indicator-platform.ios.js +0 -6
  59. package/build/components/activity-indicator/activity-indicator-platform.js +0 -5
  60. package/build/components/activity-indicator/index.svelte +0 -63
  61. package/build/components/activity-indicator/index.svelte.d.ts +0 -5
  62. package/build/components/button.svelte +0 -76
  63. package/build/components/button.svelte.d.ts +0 -5
  64. package/build/components/image/image-logic.d.ts +0 -2
  65. package/build/components/image/image-logic.js +0 -135
  66. package/build/components/image/index.d.ts +0 -3
  67. package/build/components/image/index.js +0 -10
  68. package/build/components/image/index.svelte +0 -66
  69. package/build/components/image/index.svelte.d.ts +0 -5
  70. package/build/components/image-background/index.svelte +0 -87
  71. package/build/components/image-background/index.svelte.d.ts +0 -5
  72. package/build/components/input-accessory-view/index.svelte +0 -51
  73. package/build/components/input-accessory-view/index.svelte.d.ts +0 -5
  74. package/build/components/pressable/index.svelte +0 -185
  75. package/build/components/pressable/index.svelte.d.ts +0 -9
  76. package/build/components/scroll-view/index.svelte +0 -331
  77. package/build/components/scroll-view/index.svelte.d.ts +0 -18
  78. package/build/components/scroll-view/scroll-view-platform-types.d.ts +0 -3
  79. package/build/components/scroll-view/scroll-view-platform.android.d.ts +0 -3
  80. package/build/components/scroll-view/scroll-view-platform.android.js +0 -3
  81. package/build/components/scroll-view/scroll-view-platform.d.ts +0 -1
  82. package/build/components/scroll-view/scroll-view-platform.ios.d.ts +0 -3
  83. package/build/components/scroll-view/scroll-view-platform.ios.js +0 -3
  84. package/build/components/scroll-view/scroll-view-platform.js +0 -4
  85. package/build/components/scroll-view/scroll-view-sticky-context.d.ts +0 -7
  86. package/build/components/scroll-view/scroll-view-sticky-context.js +0 -1
  87. package/build/components/scroll-view/sticky-header-props.d.ts +0 -10
  88. package/build/components/scroll-view/sticky-header-props.js +0 -1
  89. package/build/components/scroll-view/sticky-header.svelte +0 -287
  90. package/build/components/scroll-view/sticky-header.svelte.d.ts +0 -5
  91. package/build/components/switch/index.svelte +0 -134
  92. package/build/components/switch/index.svelte.d.ts +0 -5
  93. package/build/components/text-input/index.svelte +0 -372
  94. package/build/components/text-input/index.svelte.d.ts +0 -17
  95. package/build/components/touchable-highlight/index.svelte +0 -139
  96. package/build/components/touchable-highlight/index.svelte.d.ts +0 -5
  97. package/build/components/touchable-native-feedback/index.d.ts +0 -9
  98. package/build/components/touchable-native-feedback/index.js +0 -14
  99. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +0 -49
  100. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte.d.ts +0 -5
  101. package/build/components/touchable-opacity/index.svelte +0 -181
  102. package/build/components/touchable-opacity/index.svelte.d.ts +0 -5
  103. package/build/components/touchable-without-feedback/index.svelte +0 -75
  104. package/build/components/touchable-without-feedback/index.svelte.d.ts +0 -5
  105. package/build/dom-shim/fold-host-bag.d.ts +0 -1
  106. package/build/dom-shim/fold-host-bag.js +0 -7
  107. package/build/modules/animated/animated-component-props.d.ts +0 -7
  108. package/build/modules/animated/animated-component-props.js +0 -1
  109. package/build/modules/animated/create-animated-component.d.ts +0 -5
  110. package/build/modules/animated/create-animated-component.js +0 -131
  111. package/build/preprocessor/lower-host-primitives.d.ts +0 -10
  112. package/build/preprocessor/lower-host-primitives.js +0 -455
  113. package/build/state-style.d.ts +0 -1
  114. package/build/state-style.js +0 -8
  115. /package/build/components/{activity-indicator/activity-indicator-props.js → activity-indicator-props.js} +0 -0
  116. /package/build/components/{image-background/image-background-props.js → image-background-props.js} +0 -0
  117. /package/build/{components/scroll-view/scroll-view-platform-types.js → intrinsic-elements.js} +0 -0
@@ -1,20 +1,33 @@
1
- // The workhorse of the shim. Every Symbiote intrinsic (`symbiote-view`, `symbiote-text`, …)
2
- // is a hyphenated tag, so Svelte compiles it down the CUSTOM-ELEMENT codegen path (§3g):
3
- // attributes go through `set_custom_element_data`, which stringifies scalars and hard-excludes
4
- // `style`. The resolution (§3g(c)) is a single object-bag PROPERTY, not a spread of
5
- // attributes — an object always satisfies `value && typeof value === 'object'`, so it lands as
6
- // a real property SET (`node.p = bag`), untouched by stringification. `p`'s setter (below)
7
- // unpacks the bag into `routeProp`, the same entry point React's flat bag and Vue's patchProp
8
- // already use.
1
+ // The workhorse of the shim, and it has FIVE doors, not one — svelte decides which by tag name,
2
+ // by compiled namespace and by whether the attribute starts with `on`, so an element cannot
3
+ // choose its own:
4
+ //
5
+ // set_custom_element_data hyphenated tags (`text-input`, …). Stringifies a scalar; an OBJECT
6
+ // lands as a real property set, which is what makes `p={{…}}` work.
7
+ // set_attribute every other tag, and the hyphenated ones for a non-string value.
8
+ // set_class -> `setAttribute('class')` in the svg namespace, `dom.className`
9
+ // in html. Both spellings land in the same bag key.
10
+ // set_style -> `dom.style.cssText`, STRINGIFIED, plus the authored value under
11
+ // a private Symbol. See `style-cache.ts`.
12
+ // addEventListener any `on<Name>` attribute, via `$.event()`, with the `on` stripped.
13
+ //
14
+ // All five converge on one folded bag and out through `routeProp`, the entry point React's flat
15
+ // bag and Vue's patchProp already use — because only `routeProp` knows which `on*` names the
16
+ // node's ViewConfig declares as events and which are ordinary callback props a behavior reads.
17
+ // `p` is ours to name (§3g(c)); a component forwarding a whole bag emits it, app code need not.
18
+ //
19
+ // They converge, they do not merge: `p` owns its key SET, since a key dropped from the bag has to
20
+ // reset the prop, while the other four own a key each. Hence `doorBag` / `pBag` below.
9
21
  //
10
22
  // A per-key diff in the setter is MANDATORY (§3g(c)), not an optimization: Svelte's
11
23
  // `set_custom_element_data` has no early-out guard and runs on every effect re-fire, so
12
24
  // without the diff every prop gets rewritten whenever any one of them changes.
13
- import { createElement, dlog, routeProp, setEventListener, toPublicInstance, } from '@symbiote-native/engine';
25
+ import { createElement, dlog, routeProp, toPublicInstance, } from '@symbiote-native/engine';
14
26
  import { descriptorFor } from '@symbiote-native/components';
15
27
  import { normalizeSvelteClass } from '../class-value.js';
16
- import { foldHostBag } from './fold-host-bag.js';
28
+ import { CANONICAL_BY_LOWER, CANONICAL_PROP_NAMES, } from './canonical-prop-names.js';
17
29
  import { ShimNode } from './shim-node.js';
30
+ import { discoverStyleCacheKey } from './style-cache.js';
18
31
  // The bag a not-yet-live element is diffed against in onMadeLive. A module constant rather than a
19
32
  // fresh `{}` per node: it is only ever read, and the identity lets applyBagDiff skip its second
20
33
  // pass outright on the create path.
@@ -39,13 +52,27 @@ export class ShimElementBase extends ShimNode {
39
52
  export class ShimElement extends ShimElementBase {
40
53
  tagName;
41
54
  namespaceURI;
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;
55
+ // LAZY, and that is a measured decision, not a style one. A primitive carrying its whole prop
56
+ // surface in the `p` bag never writes this on the create path — but an eager field allocated a
57
+ // Map per element regardless, 9 002 of them on a 1 000-row create, in a window where GC is the
58
+ // largest single bucket (29%).
59
+ attributes = undefined;
60
+ // The four attribute doors write `doorBag`, `set p` writes `pBag`, `lastBag` is the folded merge.
61
+ // Held in ONE object, `set p` deleted the `class` `from_tree` had written at clone time, so
62
+ // `<view p={handlers} class="x">` committed with no style at all and nothing was red. Unreachable
63
+ // while an element used one door, which nearly every element does.
64
+ // Both LAZY: an element touching one door still allocates one object.
65
+ doorBag = undefined;
66
+ pBag = undefined;
48
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;
49
76
  constructor(tagName, namespaceURI) {
50
77
  super();
51
78
  this.tagName = tagName;
@@ -58,13 +85,27 @@ export class ShimElement extends ShimElementBase {
58
85
  // no-opping — a `style` attribute on a bare tag crashed the mount. LAZY, for the reason
59
86
  // `.claude/rules/svelte-shim-is-the-per-node-create-path.md` records about the two Maps below it:
60
87
  // 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;
88
+ // largest bucket. Only `set_style` touches this, and a bag-carrying element never takes it.
89
+ styleSlot = undefined;
63
90
  get style() {
64
91
  return (this.styleSlot ??= { cssText: '' });
65
92
  }
93
+ // The real `style` seam, reached from the Symbol accessor below rather than from `.style`: that
94
+ // slot only ever sees `String(value)`. Not part of the DOM surface.
95
+ writeStyleValue(value) {
96
+ this.writeBagKey('style', value);
97
+ }
98
+ // `set_class`'s html-namespace exit. Without the setter it lands as a plain JS property, and the
99
+ // failure is half-silent: a static `class="card"` rides the template through `setAttribute` and
100
+ // keeps working, so only the dynamic spelling stops styling.
101
+ get className() {
102
+ return this.lastBag.class;
103
+ }
104
+ set className(value) {
105
+ this.writeBagKey('class', value);
106
+ }
66
107
  // The single object-bag prop. The literal name is ours to choose (§3g(c)) — the adapter's
67
- // own View.svelte/Text.svelte/… emit `<symbiote-view p={bag}>`; app code never sees it.
108
+ // own View.svelte/Text.svelte/… emit `<view p={bag}>`; app code never sees it.
68
109
  get p() {
69
110
  return this.lastBag;
70
111
  }
@@ -72,7 +113,8 @@ export class ShimElement extends ShimElementBase {
72
113
  // The fold runs HERE rather than at element creation because an alias can arrive on an update
73
114
  // (`id` bound to a signal), and it runs before the diff so `lastBag` is always the folded shape
74
115
  // — otherwise a seeded default would look like a change on every single set.
75
- const next = foldHostBag(this.tagName, normalizeBagClasses(bag ?? {}));
116
+ this.pBag = bag;
117
+ const next = this.foldedBag();
76
118
  const prev = this.lastBag;
77
119
  this.lastBag = next;
78
120
  // THUNK, not a string (see debug.ts's header): this setter runs once per element per create
@@ -85,29 +127,122 @@ export class ShimElement extends ShimElementBase {
85
127
  applyBagDiff(this.engineNode, prev, next);
86
128
  this.surface?.requestCommit();
87
129
  }
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.
134
+ //
135
+ // `value` is `unknown`, not `string`: Svelte's `set_attribute` hands the raw value straight
136
+ // through for a name with no prototype setter, so an object `style` or a number arrives
137
+ // unstringified. The DOM would coerce it; we must not — `routeProp` wants the real value.
138
+ //
139
+ // The Map stays, and is not redundant: `getAttribute` must answer with the name the caller
140
+ // wrote, while the bag has been folded (`id` -> `nativeID`) by the time it is stored.
141
+ // `name` arrives LOWERCASED for every static attribute on every tag, and for a dynamic one on any
142
+ // tag whose name is not also an SVG element — the compiler settles that, so the repair has to
143
+ // happen here. One Map lookup per attribute write, no allocation; a name already canonical (or
144
+ // one we do not know) is handed on untouched. See `canonical-prop-names.ts`.
88
145
  setAttribute(name, value) {
89
146
  (this.attributes ??= new Map()).set(name, value);
147
+ this.writeBagKey(CANONICAL_BY_LOWER.get(name) ?? name, value);
90
148
  }
91
149
  getAttribute(name) {
92
150
  return this.attributes?.get(name) ?? null;
93
151
  }
94
152
  removeAttribute(name) {
95
153
  this.attributes?.delete(name);
154
+ // Same repair as the write, or a removal clears a key nothing ever set.
155
+ this.writeBagKey(CANONICAL_BY_LOWER.get(name) ?? name, undefined);
96
156
  }
157
+ // One key into the same bag `set p` writes, so the three spellings cannot drift into three
158
+ // representations. Folded on every write because the fold is idempotent by construction
159
+ // (`fold-host-bag.ts` states and tests that) — a bare tag has no other pass that could do it.
160
+ //
161
+ // Not `private`: the prototype accessors installed at the bottom of this file are the third
162
+ // writer, and they are defined from module scope because there are ~290 of them.
163
+ writeBagKey(name, value) {
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];
175
+ if (value === undefined)
176
+ delete door[name];
177
+ else
178
+ door[name] = value;
179
+ this.doorBag = door;
180
+ const prev = this.lastBag;
181
+ const next = this.foldedBag();
182
+ this.lastBag = next;
183
+ if (this.engineNode === undefined)
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
+ }
200
+ applyBagDiff(this.engineNode, prev, next);
201
+ this.surface?.requestCommit();
202
+ }
203
+ // A door key WINS over the same name inside `p`: fixed precedence, so the committed value does
204
+ // not depend on which setter svelte called last. Branched to keep a one-door element at one fold.
205
+ foldedBag() {
206
+ const door = this.doorBag;
207
+ const bag = this.pBag;
208
+ if (door === undefined)
209
+ return normalizeBagClasses(bag ?? {});
210
+ if (bag === undefined)
211
+ return normalizeBagClasses(door);
212
+ return normalizeBagClasses({ ...bag, ...door });
213
+ }
214
+ // THE FIFTH DOOR, and it converges on the same bag as the other four. Svelte turns EVERY
215
+ // `on<Name>` attribute into `$.event()`, whatever the name means to us — so this receives both
216
+ // `Press`, which the node's ViewConfig declares as an event, and `ValueChange`, which no config
217
+ // declares and which `behaviors/{switch,text-input}.ts` read as `node.props.onValueChange`.
218
+ // Calling `setEventListener` directly answered "event" for both and stashed the second under a
219
+ // name nothing dispatches to: the toggle moved natively and the app's callback never ran.
220
+ // Only `routeProp` knows which is which, so the handler goes back through the bag under the name
221
+ // the author wrote.
97
222
  addEventListener(name, handler) {
98
- (this.domListeners ??= new Map()).set(name, handler);
99
- if (this.engineNode !== undefined)
100
- setEventListener(this.engineNode, name, handler);
223
+ this.writeBagKey(propNameForEvent(name), handler);
101
224
  }
102
225
  removeEventListener(name) {
103
- this.domListeners?.delete(name);
104
- if (this.engineNode !== undefined)
105
- setEventListener(this.engineNode, name, undefined);
226
+ this.writeBagKey(propNameForEvent(name), undefined);
106
227
  }
107
228
  cloneNode(deep) {
108
229
  const clone = new ShimElement(this.tagName, this.namespaceURI);
109
230
  if (this.attributes !== undefined)
110
231
  clone.attributes = new Map(this.attributes);
232
+ // The bag, not just the Map — `from_tree` builds each template ONCE, writes its static
233
+ // attributes onto that master, and then clones per instance, so without this every attribute
234
+ // an app spells statically (`<view testID="x" class="card">`) is dropped from every instance.
235
+ // A reference copy is safe because `writeBagKey` and `set p` both publish a fresh object.
236
+ // `doorBag` too, or a `set p` on the CLONE re-folds without the master's static attributes.
237
+ clone.doorBag = this.doorBag;
238
+ clone.pBag = this.pBag;
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;
111
246
  if (deep === true) {
112
247
  for (const child of this.children)
113
248
  clone.appendChild(child.cloneNode(true));
@@ -121,22 +256,89 @@ export class ShimElement extends ShimElementBase {
121
256
  createEngineNode() {
122
257
  const descriptor = descriptorFor(this.tagName);
123
258
  // 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
259
+ // exists: `descriptor.component` is the Fabric view name (`view` -> `RCTView`), so a
125
260
  // host-behavior registry keyed by tag can only be reached from here. The argument defaults to
126
261
  // `component`, so passing it changes nothing until a behavior is registered.
127
262
  return toPublicInstance(createElement(descriptor.component, descriptor.isText, this.tagName));
128
263
  }
264
+ // One replay, because a handler is an ordinary bag key now — it arrives before the node is live
265
+ // exactly like `class` or `style` does, and leaves through the same diff.
129
266
  onMadeLive() {
130
267
  const engineNode = this.engineNode;
131
268
  if (engineNode === undefined)
132
269
  return;
133
270
  applyBagDiff(engineNode, EMPTY_BAG, this.lastBag);
134
- if (this.domListeners === undefined)
135
- return;
136
- for (const [name, handler] of this.domListeners)
137
- setEventListener(engineNode, name, handler);
138
271
  }
139
272
  }
273
+ // Svelte hands `addEventListener` the authored name minus its `on`, so `onPress` arrives as
274
+ // `Press`. Putting it back is what lets the handler re-enter through the ordinary prop bag, where
275
+ // `routeProp` decides event-vs-prop from the node's ViewConfig — the one decision this layer
276
+ // cannot make. A name that was already lowercase (`onclick` -> `click`) rebuilds as `onclick`,
277
+ // which no config declares and which therefore lands inertly in `node.props`, exactly as a DOM
278
+ // event name should on a native host.
279
+ //
280
+ // A charCode test rather than `name[0] !== name[0].toUpperCase()`: this runs per handler per
281
+ // element on the create path, and the comparison spelling allocates two strings to answer it.
282
+ function propNameForEvent(name) {
283
+ const first = name.charCodeAt(0);
284
+ if (first >= 65 /* A */ && first <= 90 /* Z */)
285
+ return `on${name}`;
286
+ return `on${name[0].toUpperCase()}${name.slice(1)}`;
287
+ }
288
+ // The seam `set_style` writes the authored value to. Discovered once, at module load, because
289
+ // svelte's key is a module-private Symbol — see `style-cache.ts` for the whole mechanism.
290
+ Object.defineProperty(ShimElement.prototype, discoverStyleCacheKey(), {
291
+ get() {
292
+ return this.p.style;
293
+ },
294
+ set(value) {
295
+ this.writeStyleValue(value);
296
+ },
297
+ });
298
+ // The setters `get_setters` has to find, so `set_custom_element_data` stops guessing. Its
299
+ // heuristic branch — reached whenever no real setter exists for the name — hands a scalar on as
300
+ // `String(value)` and assigns an object to a plain JS property the shim never reads, so a
301
+ // hyphenated tag committed `"false"` for `multiline={false}` and nothing at all for an object.
302
+ // With a setter present the raw value lands in the same folded bag `set p` and `setAttribute`
303
+ // write, and the three doors agree.
304
+ //
305
+ // ONE-TIME, at module load: ~290 defineProperty calls, nothing per node
306
+ // (`svelte-shim-is-the-per-node-create-path.md`). The instance shape does not move — these live on
307
+ // the prototype — so no element pays for them.
308
+ //
309
+ // A name the shim ALREADY owns is skipped rather than overwritten, and it is owned in TWO places
310
+ // that need two different tests. On the PROTOTYPE: `style` is a `{cssText}` slot `set_style`
311
+ // writes through, `className` is `set_class`'s html exit, `p` is the bag itself — an `in` test
312
+ // covers those, and covers a future member of the chain the day it is added.
313
+ //
314
+ // On the INSTANCE is the one that cost a device crash. `children` is a canonical prop name AND
315
+ // ShimNode's own field — the tree itself — and `in` cannot see it, because a class field lives on
316
+ // the instance and the prototype is empty at module load. Installing an accessor over it is
317
+ // harmless under vitest, where `useDefineForClassFields` makes `this.children = []` a
318
+ // `defineProperty` that shadows the accessor, and FATAL under Metro, whose loose class fields
319
+ // compile it to an assignment that the prototype setter swallows — so `this.children` read back
320
+ // `this.p.children`, i.e. undefined, and `makeLive` died iterating it on the app's ROOT node.
321
+ // Every one of the 328 headless tests passed.
322
+ //
323
+ // The probe is a real instance built before the loop, so it answers with whatever fields the
324
+ // ACTIVE transform materialises rather than a list someone has to keep. Its fields carry explicit
325
+ // `= undefined` initializers for the same reason: a bare declaration emits nothing under Metro and
326
+ // the probe would not see it.
327
+ const SHAPE_PROBE = new ShimElement('view');
328
+ for (const name of CANONICAL_PROP_NAMES) {
329
+ if (name in ShimElement.prototype)
330
+ continue;
331
+ if (Object.hasOwn(SHAPE_PROBE, name))
332
+ continue;
333
+ Object.defineProperty(ShimElement.prototype, name, {
334
+ get() {
335
+ return this.p[name];
336
+ },
337
+ set(value) {
338
+ this.writeBagKey(name, value);
339
+ },
340
+ });
341
+ }
140
342
  // Both spellings route through the engine's shared class+style merge (routeProp's
141
343
  // CLASS_PROP_KEYS), so both get the same clsx normalization here.
142
344
  const CLASS_BAG_KEYS = ['class', 'className'];
@@ -40,6 +40,7 @@ const PATCHED_KEYS = [
40
40
  'DocumentFragment',
41
41
  'document',
42
42
  'customElements',
43
+ 'HTMLMediaElement',
43
44
  ];
44
45
  // Real DOM's `CustomElementRegistry`. `set_custom_element_data` (svelte's
45
46
  // dom/elements/attributes.js) reads the BARE global `customElements` unconditionally, with no
@@ -48,11 +49,34 @@ const PATCHED_KEYS = [
48
49
  // pipeline (mount-pipeline.smoke.test.ts); `tsc --build` has no way to catch a missing global
49
50
  // a compiled Svelte bundle reads. RN/Hermes has no Custom Elements API of its own to collide
50
51
  // with (unlike `navigator`/`requestAnimationFrame`, verified against .vendors/react-native —
51
- // see the header comment above), so patching this is safe. `get()` always returns `undefined`
52
- // since we never call `customElements.define()`, which is exactly what steers
53
- // `set_custom_element_data` down the object-bag "set as property" branch every `p={bag}` prop
54
- // needs (skill §3g(c)).
55
- const FAKE_CUSTOM_ELEMENT_REGISTRY = { get: () => undefined };
52
+ // see the header comment above), so patching this is safe.
53
+ //
54
+ // `get()` returns a TRUTHY stand-in, and that is the whole reason `set_custom_element_data` can be
55
+ // made lossless. Its condition (attributes.js:245-265) is a ternary, not a fallback:
56
+ //
57
+ // setters_cache.has(nodeName) || !customElements || customElements.get(nodeName)
58
+ // ? get_setters(node).includes(prop) <- a real setter decides; the value passes through
59
+ // : value && typeof value === 'object' <- a HEURISTIC: an object is set as a property and
60
+ // every scalar is handed on as String(value)
61
+ //
62
+ // Returning `undefined` — which it did until 2026-09-07 — selects the heuristic, so
63
+ // `<text-input multiline={false}>` committed the STRING "false" and an object-valued prop with no
64
+ // prototype setter was assigned to a plain JS property and silently lost. Anything truthy steers
65
+ // it to `get_setters`, which reaches `ShimElement.prototype`'s accessors (see
66
+ // `svelte-shim-element-global-must-be-an-ancestor.md` for why that walk terminates where it does).
67
+ // One reader in the whole client runtime, so nothing else is affected.
68
+ const FAKE_CUSTOM_ELEMENT_REGISTRY = {
69
+ get: () => ShimElement,
70
+ };
71
+ // Same class of trap as `customElements` above, found the same way — by running the pipeline
72
+ // rather than by reading. Svelte's `event()` (dom/elements/events.js) ends its teardown check with
73
+ // a BARE `dom instanceof HTMLMediaElement`, no `typeof` guard, so an undeclared global throws
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 every handler went through the `p` bag.
76
+ // Nothing of ours ever derives from it, so an empty class is enough:
77
+ // `instanceof` only has to answer false.
78
+ class FakeHTMLMediaElement {
79
+ }
56
80
  let previous;
57
81
  export function patchGlobals() {
58
82
  if (previous !== undefined)
@@ -68,6 +92,7 @@ export function patchGlobals() {
68
92
  DocumentFragment: g.DocumentFragment,
69
93
  document: g.document,
70
94
  customElements: g.customElements,
95
+ HTMLMediaElement: g.HTMLMediaElement,
71
96
  };
72
97
  g.Node = ShimNode;
73
98
  // ShimElementBase, NOT ShimElement: `get_setters` stops AT `Element.prototype`, so pointing this
@@ -83,6 +108,7 @@ export function patchGlobals() {
83
108
  g.DocumentFragment = ShimDocumentFragment;
84
109
  g.document = getShimDocument();
85
110
  g.customElements = FAKE_CUSTOM_ELEMENT_REGISTRY;
111
+ g.HTMLMediaElement = FakeHTMLMediaElement;
86
112
  dlog('svelte dom-shim: patchGlobals installed');
87
113
  }
88
114
  export function restoreGlobals() {
@@ -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) {
@@ -0,0 +1 @@
1
+ export declare function discoverStyleCacheKey(): symbol;
@@ -0,0 +1,27 @@
1
+ // Where a `style={…}` attribute really goes, and why the shim cannot read it off `cssText`.
2
+ //
3
+ // Every tag name takes the same path: the compiler emits `$.set_style(node, value)` for `style`,
4
+ // on a short tag and a hyphenated one alike. `set_style` then STRINGIFIES —
5
+ // `to_style(value, undefined)` is `String(value)` — so a React Native style object arrives at
6
+ // `dom.style.cssText` as the literal `"[object Object]"`. Nothing downstream can recover it.
7
+ //
8
+ // One line later `set_style` writes the ORIGINAL value onto the element under a module-private
9
+ // `Symbol('style')` (svelte's `internal/client/constants.js`), which it also reads back as the
10
+ // change guard. That symbol is the only surviving copy, so intercepting it is the only way a
11
+ // bare `<view style={obj}>` reaches `routeProp`.
12
+ //
13
+ // The symbol is not exported, so it is DISCOVERED by running svelte's own `set_style` against a
14
+ // probe and reading the one own symbol it leaves behind — once per process, never per node. A
15
+ // version that changes the shape fails loudly here rather than silently dropping every style.
16
+ import { set_style } from 'svelte/internal/client';
17
+ export function discoverStyleCacheKey() {
18
+ const probe = { style: { cssText: '' } };
19
+ // A non-null string: `to_style` returns it unchanged, so the probe stays on the assignment
20
+ // branch and never reaches `removeAttribute`, which it does not implement.
21
+ set_style(probe, 'probe');
22
+ const symbols = Object.getOwnPropertySymbols(probe);
23
+ if (symbols.length !== 1)
24
+ throw new Error(`svelte's set_style left ${symbols.length} own symbols on a probe; ` +
25
+ 'the style-cache key cannot be identified and `style` would be dropped');
26
+ return symbols[0];
27
+ }
@@ -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
@@ -36,8 +36,8 @@ const WHITESPACE_ONLY = /^[\s\u200b-\u200d\ufeff]+$/;
36
36
  // The PARENT is what makes this exact rather than a heuristic. Measured on svelte 5.56.8, a
37
37
  // stray gap and an {#each} text placeholder are the same ' ' string in the from_tree template:
38
38
  //
39
- // stray gap ['symbiote-view', null, [...], ' ', [...]] parent takes no raw text -> drop
40
- // placeholder ['symbiote-text', null, ' '] parent IS a <Text> -> keep
39
+ // stray gap ['view', null, [...], ' ', [...]] parent takes no raw text -> drop
40
+ // placeholder ['text', null, ' '] parent IS a <Text> -> keep
41
41
  //
42
42
  // So `<Text><Text>a</Text> <Text>b</Text></Text>` keeps its separator, correctly — there the
43
43
  // space really is a word boundary. This also covers the one shape the source preprocessor
@@ -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;
@@ -1,5 +1,4 @@
1
1
  import { type IHostInstance } from '@symbiote-native/engine';
2
- import type { ShimElement } from './dom-shim';
3
2
  export type { IHostInstance } from '@symbiote-native/engine';
4
- export declare function hostInstance(shim: ShimElement | null | undefined): IHostInstance | undefined;
5
- export declare function findNodeHandle(componentOrHandle: ShimElement | number | null | undefined): number | null;
3
+ export declare function hostInstance(shim: unknown): IHostInstance | undefined;
4
+ export declare function findNodeHandle(componentOrHandle: unknown): number | null;
@@ -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
@@ -26,8 +26,14 @@ function isShimElement(value) {
26
26
  // the toPublicInstance call below is the identity; this helper exists only to give app code a correctly
27
27
  // TYPED accessor off the SHIM value (`ShimElement`) instead of the bare `.engineNode` field,
28
28
  // with no `as` cast at the call site.
29
+ // `unknown` rather than `ShimElement`, and the guard is what makes that sound. svelte2tsx bakes a
30
+ // `bind:this` return type out of `lib.dom`'s tag maps — an SVG element type for `view`/`text`/
31
+ // `image`/`switch`, `any` for the other seventeen — and neither map accepts augmentation
32
+ // (`src/intrinsic-elements.ts` records the measurement). So an app CANNOT declare its ref as
33
+ // `ShimElement` without a cast, and this is the accessor that exists to keep casts out of app
34
+ // code. Declaring the parameter `unknown` moves the narrowing where it can actually be checked.
29
35
  export function hostInstance(shim) {
30
- if (shim === null || shim === undefined)
36
+ if (!isShimElement(shim))
31
37
  return undefined;
32
38
  const node = shim.engineNode;
33
39
  return node !== undefined && isSymbioteNode(node)
@@ -44,7 +50,7 @@ export function findNodeHandle(componentOrHandle) {
44
50
  const node = componentOrHandle.engineNode;
45
51
  if (node !== undefined && isSymbioteNode(node)) {
46
52
  const tag = getNativeTag(node) ?? null;
47
- dlog(`findNodeHandle: component=${node.component} tag=${tag}`);
53
+ dlog(`findNodeHandle: component=${componentOf(node)} tag=${tag}`);
48
54
  return tag;
49
55
  }
50
56
  dlog('findNodeHandle: hostShim not live yet (engineNode undefined)');