@symbiote-native/svelte 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/build/bootstrap.js +1 -1
  2. package/build/class-value.js +1 -1
  3. package/build/components/RefreshControl.svelte +4 -2
  4. package/build/components/Text.svelte +11 -2
  5. package/build/components/View.svelte +3 -1
  6. package/build/components/activity-indicator/index.svelte +8 -2
  7. package/build/components/button.svelte +8 -3
  8. package/build/components/flat-list/index.svelte +46 -11
  9. package/build/components/image/image-logic.js +13 -3
  10. package/build/components/image-background/index.svelte +8 -2
  11. package/build/components/index.d.ts +2 -2
  12. package/build/components/input-accessory-view/index.svelte +4 -1
  13. package/build/components/keyboard-avoiding-view/index.svelte +34 -11
  14. package/build/components/modal/index.svelte +4 -1
  15. package/build/components/pressable/index.svelte +10 -3
  16. package/build/components/pressable/pressable-props.d.ts +1 -1
  17. package/build/components/scroll-view/index.svelte +54 -21
  18. package/build/components/scroll-view/sticky-header.svelte +49 -16
  19. package/build/components/section-list/index.svelte +6 -1
  20. package/build/components/switch/index.svelte +14 -3
  21. package/build/components/text-input/index.svelte +22 -6
  22. package/build/components/touchable-highlight/index.svelte +114 -15
  23. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  24. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +7 -2
  25. package/build/components/touchable-opacity/index.svelte +112 -56
  26. package/build/components/touchable-without-feedback/index.svelte +59 -4
  27. package/build/components/virtualized-list/index.svelte +233 -70
  28. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  29. package/build/components/virtualized-section-list/index.svelte +56 -9
  30. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  31. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  32. package/build/create-portal/index.d.ts +22 -0
  33. package/build/create-portal/index.js +160 -0
  34. package/build/descriptor-to-svelte.js +15 -21
  35. package/build/dom-shim/index.d.ts +1 -0
  36. package/build/dom-shim/index.js +3 -0
  37. package/build/dom-shim/text.js +38 -1
  38. package/build/host-instance.js +7 -2
  39. package/build/index.d.ts +5 -3
  40. package/build/index.js +16 -11
  41. package/build/modules/animated/create-animated-component.d.ts +5 -0
  42. package/build/modules/animated/create-animated-component.js +131 -0
  43. package/build/modules/animated/index.d.ts +24 -14
  44. package/build/modules/animated/index.js +30 -30
  45. package/build/modules/status-bar/index.js +1 -1
  46. package/build/modules/status-bar/index.svelte +4 -1
  47. package/build/native-view-bridge.d.ts +1 -1
  48. package/build/native-view-bridge.js +1 -1
  49. package/build/preprocessor/collapse-text-whitespace.js +20 -7
  50. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  51. package/build/preprocessor/scoped-styles.js +141 -104
  52. package/build/render.js +48 -3
  53. package/build/scope-token.d.ts +3 -2
  54. package/build/scope-token.js +6 -17
  55. package/build/style-scope.d.ts +2 -1
  56. package/build/style-scope.js +10 -8
  57. package/metro-svelte-transformer.cjs +1 -1
  58. package/package.json +7 -7
  59. package/build/modules/animated/AnimatedImage.svelte +0 -115
  60. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  61. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  62. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  63. package/build/modules/animated/AnimatedText.svelte +0 -58
  64. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  65. package/build/modules/animated/AnimatedView.svelte +0 -95
  66. package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
@@ -19,17 +19,19 @@
19
19
  // node-transform to a source rewrite because Svelte's compiler exposes no AST hook.
20
20
  //
21
21
  // Four steps, in order:
22
- // 1. parse the block out and compile it through @symbiote-native/css-parser, exactly like Vue's
23
- // <style scoped> — every class registers under a per-file-suffixed key, `card` ->
24
- // `card__svelte-<hash>`, in the same global registry App.css and Vue SFC blocks populate.
25
- // 2. rewrite `class` in THIS file's own markup to name the suffixed key. Static values are
26
- // resolved here, at build time; a dynamic `class={expr}` is wrapped in a runtime call to
27
- // `scopeSvelteClass` (../style-scope) — the only shape that can scope a value the compiler
28
- // cannot see into.
22
+ // 1. cut the block out TEXTUALLY (see STYLE_TAG_PATTERN) and compile it through
23
+ // @symbiote-native/css-parser's `compileScopedCss`, exactly like Vue's <style scoped> —
24
+ // lightningcss renames every class this file owns, `card` -> `card__svelte-<hash>`, and
25
+ // hands back the name map; the styles register under those names in the same global
26
+ // registry App.css and Vue SFC blocks populate.
27
+ // 2. rewrite `class` in THIS file's own markup by READING that map — never by re-deriving the
28
+ // name, which is how the two halves used to be two implementations of "what is this class
29
+ // called now". Static values resolve here, at build time; a dynamic `class={expr}` is
30
+ // wrapped in a runtime call to `scopeSvelteClass` (../style-scope) that resolves through the
31
+ // SAME map, emitted beside it.
29
32
  // 3. delete the `<style>` block from the source handed on, so Svelte emits no
30
33
  // `css_unused_selector` warnings and adds no scope hash of its own.
31
- // 4. append one `<script module>` line holding the `registerStyles()` call and the two
32
- // per-file constants step 2's rewrite refers to.
34
+ // 4. append one `<script module>` line holding the `registerRules()` call and the name map.
33
35
  //
34
36
  // SCOPING SEMANTICS, AND THE ONE DELIBERATE DIVERGENCE FROM SVELTE-ON-THE-WEB. Svelte scopes by
35
37
  // FILE: only markup written in this file carries the scope, never markup a child component owns
@@ -52,7 +54,7 @@
52
54
  // existing `<script module>` tag's own line — so every original line keeps its number and a
53
55
  // `svelte-check` diagnostic still points at the right place.
54
56
  import { parse } from 'svelte/compiler';
55
- import { classTokensIn, compile as compilePreprocessor, globalClassNamesIn, globalClassTokensIn, hashFilePath, parseCSS, } from '@symbiote-native/css-parser';
57
+ import { compile as compilePreprocessor, compileScopedCss, hashFilePath, } from '@symbiote-native/css-parser';
56
58
  // Resolved by package SELF-REFERENCE, not the relative `../scope-token` the rest of this package
57
59
  // would write — same reason metro-svelte-transformer.cjs reaches for
58
60
  // `@symbiote-native/svelte/preprocessor`. `svelte.config.js` is loaded directly by Node
@@ -62,19 +64,19 @@ import { classTokensIn, compile as compilePreprocessor, globalClassNamesIn, glob
62
64
  // in this workspace, `build` in a published install — so one line works in both. `scope-token`
63
65
  // specifically, never `style-scope`: that one reaches the engine through `class-value`, a whole
64
66
  // extensionless graph this build-time file must not pull in.
65
- import { scopeToken } from '@symbiote-native/svelte/scope-token';
67
+ import { scopeToken, } from '@symbiote-native/svelte/scope-token';
66
68
  const SCOPE_ID_PREFIX = 'svelte-';
67
69
  const ENGINE_MODULE = '@symbiote-native/engine';
68
70
  const RUNTIME_MODULE = '@symbiote-native/svelte/style-scope';
69
71
  // Every injected identifier is `__symbiote`-prefixed and import-aliased so a component that
70
- // happens to declare its own `registerStyles` / `scopeSvelteClass` cannot collide with it.
71
- const REGISTER_STYLES_LOCAL = '__symbioteRegisterStyles';
72
+ // happens to declare its own `registerRules` / `scopeSvelteClass` cannot collide with it.
73
+ const REGISTER_RULES_LOCAL = '__symbioteRegisterRules';
72
74
  const SCOPE_CLASS_LOCAL = '__symbioteScopeClass';
73
75
  const SCOPED_NAMES_LOCAL = '__symbioteScopedNames';
74
- const SCOPE_ID_LOCAL = '__symbioteScopeId';
75
76
  // A `<style lang="…">` names its language directly, unlike a standalone file identified by its
76
77
  // extension — so this is its own lookup rather than css-parser's extension-keyed
77
78
  // `detectLanguage()`. Same table Vue's transformer keeps for the identical reason.
79
+ const DEFAULT_STYLE_LANG = 'css';
78
80
  const STYLE_LANG_TO_PREPROCESSOR = new Map([
79
81
  ['css', 'css'],
80
82
  ['scss', 'scss'],
@@ -93,55 +95,82 @@ export function scopedStyles() {
93
95
  // this saves them a full parse on every Metro transform.
94
96
  if (!content.includes('<style'))
95
97
  return { code: content };
96
- const ast = parse(content, { filename, modern: true });
97
- const styleBlock = readStyleBlock(ast);
98
+ const styleBlock = findStyleBlock(content);
98
99
  if (styleBlock === undefined)
99
100
  return { code: content };
100
101
  const path = filename ?? 'component.svelte';
101
102
  const css = await compileStyleBlock(styleBlock, path);
102
- const parsed = parseCSS(css, { filename: path });
103
- const exemptFromScope = globalClassNamesIn(css);
104
- const scopeId = SCOPE_ID_PREFIX + hashFilePath(path);
105
103
  // A `:global(...)` selector opts out of scoping and registers under its plain name, the
106
- // same escape hatch Vue's <style scoped> has. Everything else is suffixed and its ORIGINAL
107
- // name recorded, so the markup rewrite below knows which tokens this file owns.
108
- //
109
- // A COMPOUND or DESCENDANT selector registers under one collapsed key (`.card.big` ->
110
- // `cardBig`) that appears nowhere in the markup — the markup says `class="card big"`. So
111
- // the key alone is not enough to know what this file owns: its TOKENS are recorded too, or
112
- // a `.card.big` rule whose parts have no standalone rule of their own leaves both tokens
113
- // unscoped and the rule unreachable.
114
- //
115
- // A token out of a `:global(...)` payload is the one exception: in `.card :global(.reset)`
116
- // the collapsed KEY is this file's own, because `.card` is, but `reset` was written
117
- // precisely to name markup this file does not own. Suffixing it along with the rest of its
118
- // chain scope-mangles the escape hatch into matching nothing.
119
- const tokensByName = classTokensIn(css, { filename: path });
120
- const globalTokens = globalClassTokensIn(css, { filename: path });
121
- const styles = {};
122
- const localNames = new Set();
123
- for (const [className, props] of Object.entries(parsed)) {
124
- const isExempt = exemptFromScope.has(className);
125
- const registeredName = isExempt ? className : `${className}__${scopeId}`;
126
- if (!isExempt) {
127
- localNames.add(className);
128
- for (const token of tokensByName.get(className) ?? []) {
129
- if (!globalTokens.has(token))
130
- localNames.add(token);
131
- }
132
- }
133
- styles[registeredName] = props;
134
- }
135
- const edits = [blankOut(styleBlock.start, styleBlock.end, content)];
136
- collectClassEdits(fragmentNodes(ast), content, localNames, scopeId, edits);
137
- if (Object.keys(styles).length > 0) {
138
- edits.push(injectModuleScript(ast, content, styles, localNames, scopeId));
104
+ // same escape hatch Vue's <style scoped> has. lightningcss decides that — a name it did not
105
+ // rename is absent from `names` and every token of it passes through untouched below.
106
+ const { rules, names } = compileScopedCss(css, {
107
+ filename: path,
108
+ pattern: `[local]__${SCOPE_ID_PREFIX}${hashFilePath(path)}`,
109
+ });
110
+ // Parsed WITHOUT the style block, and with it replaced by same-length whitespace, so every
111
+ // offset the AST reports still indexes `content` itself.
112
+ const ast = parse(withoutStyleBlock(content, styleBlock), {
113
+ filename,
114
+ modern: true,
115
+ });
116
+ const edits = [
117
+ blankOut(styleBlock.start, styleBlock.end, content),
118
+ ];
119
+ collectClassEdits(fragmentNodes(ast), content, names, edits);
120
+ if (rules.length > 0) {
121
+ edits.push(injectModuleScript(ast, content, rules, names));
139
122
  }
140
123
  return { code: applyEdits(content, edits) };
141
124
  },
142
125
  };
143
126
  }
144
127
  // ---------------------------------------------------------------------------------------------
128
+ // The <style> block, located WITHOUT parsing it.
129
+ //
130
+ // This is svelte's own tag regex (src/compiler/preprocess/index.js), which is what its official
131
+ // `preprocess()` style hook uses to hand a block's content to a preprocessor unparsed — and the
132
+ // reason `svelte-preprocess` can compile SCSS at all. Reading `parse().css` instead, as this file
133
+ // did until 2026-08-20, validates the block as CSS whatever `lang` says: `<style lang="scss">$pad:
134
+ // 7px;</style>` threw `css_expected_identifier` before the language table above was ever
135
+ // consulted, so only SCSS that is already valid CSS (nesting) survived. A `<!-- -->` comment is an
136
+ // alternative of the pattern purely so a commented-out block is skipped rather than matched.
137
+ const STYLE_TAG_PATTERN = /<!--[^]*?-->|<style((?:\s+[^=>'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/style>)/g;
138
+ const LANG_ATTRIBUTE_PATTERN = /\blang\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/;
139
+ function findStyleBlock(content) {
140
+ for (const match of content.matchAll(STYLE_TAG_PATTERN)) {
141
+ const [text, attributes, source] = match;
142
+ if (attributes === undefined || source === undefined)
143
+ continue;
144
+ return {
145
+ start: match.index,
146
+ end: match.index + text.length,
147
+ source,
148
+ lang: readLang(attributes),
149
+ };
150
+ }
151
+ return undefined;
152
+ }
153
+ function readLang(attributes) {
154
+ const match = LANG_ATTRIBUTE_PATTERN.exec(attributes);
155
+ if (match === null)
156
+ return DEFAULT_STYLE_LANG;
157
+ return match[1] ?? match[2] ?? match[3] ?? DEFAULT_STYLE_LANG;
158
+ }
159
+ // Same byte length, newlines kept: the AST's offsets stay valid against the ORIGINAL source, and
160
+ // svelte's own diagnostics keep their line numbers.
161
+ function withoutStyleBlock(content, block) {
162
+ const blanked = content.slice(block.start, block.end).replace(/[^\n]/g, ' ');
163
+ return content.slice(0, block.start) + blanked + content.slice(block.end);
164
+ }
165
+ async function compileStyleBlock(block, filename) {
166
+ const language = STYLE_LANG_TO_PREPROCESSOR.get(block.lang);
167
+ if (language === undefined) {
168
+ throw new Error(`${filename}: <style lang="${block.lang}"> is not supported — use css, scss, sass, less or stylus.`);
169
+ }
170
+ const path = block.lang === INDENTED_SASS_LANG ? `${filename}.sass` : filename;
171
+ return compilePreprocessor(block.source, language, path);
172
+ }
173
+ // ---------------------------------------------------------------------------------------------
145
174
  // AST reading. `parse()`'s return type is not pinned here (same call, same reasoning as
146
175
  // forbid-web-only-constructs.ts): it is read through runtime guards on `unknown` rather than
147
176
  // trusting a shape nothing verifies.
@@ -156,43 +185,6 @@ function stringAt(node, key) {
156
185
  const value = node[key];
157
186
  return typeof value === 'string' ? value : undefined;
158
187
  }
159
- function readStyleBlock(ast) {
160
- if (!isRecord(ast))
161
- return undefined;
162
- const css = ast.css;
163
- if (!isRecord(css))
164
- return undefined;
165
- const start = numberAt(css, 'start');
166
- const end = numberAt(css, 'end');
167
- const content = css.content;
168
- if (start === undefined || end === undefined || !isRecord(content))
169
- return undefined;
170
- const source = stringAt(content, 'styles');
171
- if (source === undefined)
172
- return undefined;
173
- return { start, end, source, lang: readLangAttribute(css) };
174
- }
175
- function readLangAttribute(block) {
176
- const attributes = block.attributes;
177
- if (!Array.isArray(attributes))
178
- return 'css';
179
- for (const attribute of attributes) {
180
- if (!isRecord(attribute) || attribute.name !== 'lang')
181
- continue;
182
- const text = staticTextOf(attribute);
183
- if (text !== undefined)
184
- return text;
185
- }
186
- return 'css';
187
- }
188
- async function compileStyleBlock(block, filename) {
189
- const language = STYLE_LANG_TO_PREPROCESSOR.get(block.lang);
190
- if (language === undefined) {
191
- throw new Error(`${filename}: <style lang="${block.lang}"> is not supported — use css, scss, sass, less or stylus.`);
192
- }
193
- const path = block.lang === INDENTED_SASS_LANG ? `${filename}.sass` : filename;
194
- return compilePreprocessor(block.source, language, path);
195
- }
196
188
  function fragmentNodes(ast) {
197
189
  if (!isRecord(ast))
198
190
  return [];
@@ -215,17 +207,23 @@ function nestedNodes(node) {
215
207
  }
216
208
  return nested;
217
209
  }
218
- function collectClassEdits(nodes, content, localNames, scopeId, edits) {
210
+ function collectClassEdits(nodes, content, names, edits) {
219
211
  for (const node of nodes) {
220
212
  if (!isRecord(node))
221
213
  continue;
222
214
  if (node.type === 'Attribute' && node.name === 'class') {
223
- const edit = classAttributeEdit(node, content, localNames, scopeId);
215
+ const edit = classAttributeEdit(node, content, names);
216
+ if (edit !== undefined)
217
+ edits.push(edit);
218
+ continue;
219
+ }
220
+ if (node.type === 'ClassDirective') {
221
+ const edit = classDirectiveEdit(node, content, names);
224
222
  if (edit !== undefined)
225
223
  edits.push(edit);
226
224
  continue;
227
225
  }
228
- collectClassEdits(nestedNodes(node), content, localNames, scopeId, edits);
226
+ collectClassEdits(nestedNodes(node), content, names, edits);
229
227
  }
230
228
  }
231
229
  // `value` is `true` for a bare `class` attribute, one node for a lone value, or an array mixing
@@ -254,7 +252,7 @@ function staticTextOf(attribute) {
254
252
  }
255
253
  return text;
256
254
  }
257
- function classAttributeEdit(attribute, content, localNames, scopeId) {
255
+ function classAttributeEdit(attribute, content, names) {
258
256
  const start = numberAt(attribute, 'start');
259
257
  const end = numberAt(attribute, 'end');
260
258
  const parts = attributeValueParts(attribute);
@@ -265,7 +263,7 @@ function classAttributeEdit(attribute, content, localNames, scopeId) {
265
263
  const scoped = staticValue
266
264
  .split(/\s+/)
267
265
  .filter(Boolean)
268
- .map(token => scopeToken(token, localNames, scopeId))
266
+ .map(token => scopeToken(token, names))
269
267
  .join(' ');
270
268
  // Nothing this file owns appears in the value — leave the source byte-identical rather than
271
269
  // re-quoting it, so a component with only `:global(...)` rules is untouched.
@@ -275,8 +273,8 @@ function classAttributeEdit(attribute, content, localNames, scopeId) {
275
273
  }
276
274
  // A dynamic value can only be scoped at runtime, and only if this file defines something to
277
275
  // scope against. With no local names there is nothing for the call to do, so it is not emitted
278
- // at all — and the two constants it would reference stay out of the bundle.
279
- if (localNames.size === 0)
276
+ // at all — and the map it would reference stays out of the bundle.
277
+ if (names.size === 0)
280
278
  return undefined;
281
279
  const expression = expressionSourceOf(parts, content);
282
280
  if (expression === undefined)
@@ -284,15 +282,36 @@ function classAttributeEdit(attribute, content, localNames, scopeId) {
284
282
  return {
285
283
  start,
286
284
  end,
287
- text: `class={${SCOPE_CLASS_LOCAL}(${expression}, ${SCOPED_NAMES_LOCAL}, ${SCOPE_ID_LOCAL})}`,
285
+ text: `class={${SCOPE_CLASS_LOCAL}(${expression}, ${SCOPED_NAMES_LOCAL})}`,
288
286
  };
289
287
  }
288
+ // `class:card={cond}` carries its class name in the DIRECTIVE, where the value rewrite above
289
+ // cannot see it. Svelte rejects the directive on a COMPONENT (`component_invalid_directive`), so
290
+ // only a host element reaches here — rewritten anyway, because a token left unscoped names a rule
291
+ // that registered under the scoped name and is silently dead. The shorthand `class:card` expands
292
+ // to `class:card__<scope>={card}`: the class is scoped, the variable it reads keeps its own name.
293
+ function classDirectiveEdit(directive, content, names) {
294
+ const name = stringAt(directive, 'name');
295
+ const start = numberAt(directive, 'start');
296
+ const end = numberAt(directive, 'end');
297
+ if (name === undefined || start === undefined || end === undefined)
298
+ return undefined;
299
+ const scoped = scopeToken(name, names);
300
+ if (scoped === name)
301
+ return undefined;
302
+ const expression = sliceExpression(directive, content);
303
+ if (expression === undefined)
304
+ return undefined;
305
+ return { start, end, text: `class:${scoped}={${expression}}` };
306
+ }
290
307
  // A lone `class={expr}` reproduces `expr` verbatim, so an arbitrary expression — a clsx array, a
291
308
  // ternary, a function call — passes through untouched and only its RESULT gets scoped. An
292
309
  // interpolated `class="a {b} c"` is rebuilt as the template literal Svelte itself would have
293
310
  // concatenated, so both shapes reduce to one expression the runtime helper can take.
294
311
  function expressionSourceOf(parts, content) {
295
- if (parts.length === 1 && isRecord(parts[0]) && parts[0].type === 'ExpressionTag') {
312
+ if (parts.length === 1 &&
313
+ isRecord(parts[0]) &&
314
+ parts[0].type === 'ExpressionTag') {
296
315
  return sliceExpression(parts[0], content);
297
316
  }
298
317
  let literal = '';
@@ -326,18 +345,36 @@ function sliceExpression(part, content) {
326
345
  return content.slice(start, end);
327
346
  }
328
347
  function escapeTemplateLiteral(text) {
329
- return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
348
+ return text
349
+ .replace(/\\/g, '\\\\')
350
+ .replace(/`/g, '\\`')
351
+ .replace(/\$\{/g, '\\${');
330
352
  }
331
353
  // ---------------------------------------------------------------------------------------------
332
354
  // Emitting
333
- function injectModuleScript(ast, content, styles, localNames, scopeId) {
355
+ // `combinators` is compile-time-only — the registry matches by token subset and never reads it —
356
+ // so it is stripped rather than shipped in every app bundle. Same cut `@symbiote-native/css-
357
+ // parser`'s `serializeRules` makes for a standalone `.css` file; a Svelte `<style>` block has no
358
+ // reason to ship more.
359
+ function serializeRules(rules) {
360
+ return JSON.stringify(rules.map(({ tokens, specificity, order, style }) => ({
361
+ tokens,
362
+ specificity,
363
+ order,
364
+ style,
365
+ })));
366
+ }
367
+ function injectModuleScript(ast, content, rules, names) {
334
368
  const lines = [
335
- `import { registerStyles as ${REGISTER_STYLES_LOCAL} } from '${ENGINE_MODULE}';`,
336
- `${REGISTER_STYLES_LOCAL}(${JSON.stringify(styles)});`,
369
+ `import { registerRules as ${REGISTER_RULES_LOCAL} } from '${ENGINE_MODULE}';`,
370
+ `${REGISTER_RULES_LOCAL}(${serializeRules(rules)});`,
337
371
  ];
338
- if (localNames.size > 0) {
372
+ if (names.size > 0) {
339
373
  lines.unshift(`import { scopeSvelteClass as ${SCOPE_CLASS_LOCAL} } from '${RUNTIME_MODULE}';`);
340
- lines.push(`const ${SCOPED_NAMES_LOCAL} = new Set(${JSON.stringify([...localNames])});`, `const ${SCOPE_ID_LOCAL} = ${JSON.stringify(scopeId)};`);
374
+ // The SAME map the static rewrite above read, shipped verbatim: a dynamic value's tokens are
375
+ // only known at runtime, and re-deriving their names there is the divergence this map exists
376
+ // to make impossible.
377
+ lines.push(`const ${SCOPED_NAMES_LOCAL} = new Map(${JSON.stringify([...names])});`);
341
378
  }
342
379
  const source = lines.join(' ');
343
380
  // Svelte rejects a second `<script module>`, so an existing one is spliced into rather than
package/build/render.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // believing it is talking to the real DOM. Decided during Svelte adapter planning
4
4
  // (2026-08-11, svelte-adapter-dom-shim skill §10): single root per process, so
5
5
  // patchGlobals()/restoreGlobals() need no ref-counting.
6
- import { mount as svelteMount, unmount as svelteUnmount } from 'svelte';
7
- import { createSurface, disposeRoot, dlog, } from '@symbiote-native/engine';
6
+ import { mount as svelteMount, unmount as svelteUnmount, } from 'svelte';
7
+ import { createSurface, disposeRoot, dlog, reportUncaughtError, } from '@symbiote-native/engine';
8
8
  import { patchGlobals, restoreGlobals } from './dom-shim/index.js';
9
9
  import { createRootShimElement } from './root-element.js';
10
10
  // One Svelte app per surface — same re-mount-starts-clean shape as every other adapter's
@@ -18,6 +18,22 @@ function teardown(rootTag) {
18
18
  apps.delete(rootTag);
19
19
  disposeRoot(rootTag);
20
20
  }
21
+ // `transformError` is svelte's own mount-level hook for errors a `<svelte:boundary>` is about to
22
+ // HANDLE - upstream reaches for it to run SvelteKit's `handleError`. Svelte calls it only from
23
+ // Boundary.#handle_error (dom/blocks/boundary.js), i.e. only once some boundary with an `onerror`
24
+ // or a `failed` snippet has claimed the error, and it inherits down the boundary tree, so one
25
+ // hook at mount covers every boundary the app writes. We use it as a read-only tap and return the
26
+ // error untouched, so the `failed` snippet still receives the real thing.
27
+ //
28
+ // Why this is `dlog` and NOT reportUncaughtError - read this before "fixing" it back: writing a
29
+ // `<svelte:boundary>` IS the developer saying "this can throw and I am handling it here".
30
+ // Answering that with a full-screen redbox over the fallback the app just rendered contradicts
31
+ // what the app asked for. The UNCAUGHT path below still reports; the only difference is whether
32
+ // anyone claimed the error. Same asymmetry as the React adapter's onCaughtError.
33
+ function tapBoundaryError(error) {
34
+ dlog(() => `svelte render (caught by <svelte:boundary>): ${String(error)}`);
35
+ return error;
36
+ }
21
37
  export function mount(rootTag, RootComponent, props) {
22
38
  teardown(rootTag);
23
39
  // Safe to install unconditionally - patchGlobals() is itself idempotent if a surface is
@@ -25,7 +41,36 @@ export function mount(rootTag, RootComponent, props) {
25
41
  patchGlobals();
26
42
  const surface = createSurface(rootTag);
27
43
  const target = createRootShimElement(surface);
28
- const svelteApp = svelteMount(RootComponent, { target, props: props ?? {} });
44
+ let svelteApp;
45
+ try {
46
+ svelteApp = svelteMount(RootComponent, {
47
+ target,
48
+ props: props ?? {},
49
+ transformError: tapBoundaryError,
50
+ });
51
+ }
52
+ catch (error) {
53
+ // Svelte has no mount-level hook for an UNCAUGHT error, so the seam is the throw itself:
54
+ // while a subtree is still being created, error-handling.js rethrows synchronously
55
+ // (`handle_error` bails out before REACTION_RAN is set, and the implicit root boundary
56
+ // `_mount` installs carries only a `pending` snippet, so it re-throws too). Without this the
57
+ // throw left the surface half-committed - nothing painted, nothing logged, a blank screen.
58
+ //
59
+ // Reported AND rethrown, deliberately: the report is the channel we control and is the whole
60
+ // point of this seam, while the rethrow keeps upstream's contract that `mount()` fails loudly
61
+ // - an app or a test harness wrapping mount() in its own try/catch must still see the error.
62
+ //
63
+ // Reaches the SYNCHRONOUS mount-time throw only. An error raised later - a reactive update,
64
+ // an `$effect` body, a rejected `{#await}` - is rethrown from inside svelte's own microtask
65
+ // flush (queue_micro_task, internal/client/dom/task.js), past any try/catch of ours; those
66
+ // land on the host's uncaught-exception path. `flushSync()` here would pull the mount-time
67
+ // `$effect` case in, at the price of running user effects BEFORE the engine's first commit,
68
+ // where getNativeTag() is still undefined - a worse bug than the one it closes.
69
+ reportUncaughtError(error, {
70
+ origin: 'svelte render (no <svelte:boundary>)',
71
+ });
72
+ throw error;
73
+ }
29
74
  apps.set(rootTag, { svelteApp });
30
75
  return surface;
31
76
  }
@@ -1,2 +1,3 @@
1
- export declare function scopeToken(token: string, localNames: ReadonlySet<string>, scopeId: string): string;
2
- export declare function kebabToCamel(value: string): string;
1
+ /** Authored class name -> the name it registers under. One spelling: nothing camelCases now. */
2
+ export type IScopedNames = ReadonlyMap<string, string>;
3
+ export declare function scopeToken(token: string, names: IScopedNames): string;
@@ -1,4 +1,7 @@
1
- // The `<style>`-scoping rule for ONE class token, and nothing else.
1
+ // The `<style>`-scoping rule for ONE class token, and nothing else: a lookup in the name map
2
+ // @symbiote-native/css-parser built out of lightningcss's `exports`. A token the map does not
3
+ // carry is one this file's `<style>` block never renamed — a `:global(...)` name, or a class
4
+ // forwarded from somewhere else — and is returned VERBATIM.
2
5
  //
3
6
  // WHY THIS IS ITS OWN FILE — it looks like it belongs in `./style-scope`, and it cannot live
4
7
  // there. The build-time half (`preprocessor/scoped-styles.ts`) and the runtime half
@@ -14,20 +17,6 @@
14
17
  //
15
18
  // Keep this file import-free. Adding one import to it breaks `svelte-check` for every consuming
16
19
  // app, and nothing in this package's own test run would notice.
17
- const SCOPE_SEPARATOR = '__';
18
- // A token is matched in its camelCase form because that is the only form the registry is keyed
19
- // in (@symbiote-native/css-parser's `extractClassName` always camelCases, so `.card-title`
20
- // registers as `cardTitle`) while markup idiomatically writes the kebab form. A token this file's
21
- // `<style>` block does NOT define is returned VERBATIM, not camelCased — unlike Vue's rewriter,
22
- // which camelCases everything it sees. The engine's `resolveOne` already falls back kebab->camel
23
- // on a miss, so camelCasing here would buy nothing and would silently rename a class the author
24
- // is forwarding to some other file's global rule.
25
- export function scopeToken(token, localNames, scopeId) {
26
- const camelToken = kebabToCamel(token);
27
- return localNames.has(camelToken) ? camelToken + SCOPE_SEPARATOR + scopeId : token;
28
- }
29
- // Duplicated from @symbiote-native/css-parser rather than imported, for the same reason
30
- // core/engine/src/style-registry/index.ts duplicates it — plus the import-free constraint above.
31
- export function kebabToCamel(value) {
32
- return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
20
+ export function scopeToken(token, names) {
21
+ return names.get(token) ?? token;
33
22
  }
@@ -1 +1,2 @@
1
- export declare function scopeSvelteClass(value: unknown, localNames: ReadonlySet<string>, scopeId: string): unknown;
1
+ import { type IScopedNames } from './scope-token';
2
+ export declare function scopeSvelteClass(value: unknown, names: IScopedNames): unknown;
@@ -2,14 +2,16 @@
2
2
  // scoped-styles.ts` — rewrites every STATIC class token in a component's own markup while it
3
3
  // still has the source text. A DYNAMIC `class={expr}` has no tokens to read at build time, so
4
4
  // its value is wrapped in a call to `scopeSvelteClass` instead. Same split Vue's scoped-style
5
- // path already uses (`scopeClassName` in @symbiote-native/engine, symbiote-sfc-style-compiler §5).
6
- // Both halves apply the identical per-token rule by sharing `./scope-token` — see that file for
7
- // why the rule is not simply declared here.
5
+ // path already uses (`renameClassTokens` in @symbiote-native/engine, symbiote-sfc-style-compiler
6
+ // §5).
7
+ // Both halves resolve every token through the SAME name map `@symbiote-native/css-parser` built
8
+ // from lightningcss's `exports`, applied by `./scope-token` — see that file for why the lookup is
9
+ // not simply declared here.
8
10
  //
9
- // WHY NOT REUSE THE ENGINE'S `scopeClassName` DIRECTLY. Its input surface is Vue's:
11
+ // WHY NOT REUSE THE ENGINE'S `renameClassTokens` DIRECTLY. Its input surface is Vue's:
10
12
  // `string | Record<string, boolean> | Array<those>`. Svelte's is wider — `class={['card', cond &&
11
- // 'on']}` leaves a literal `false` in the array, which `scopeClassName` hands to `.split()` and
12
- // throws on. Widening a function the other three adapters depend on, to serve one adapter's
13
+ // 'on']}` leaves a literal `false` in the array, which `renameClassTokens` hands to `.split()`
14
+ // and throws on. Widening a function the other three adapters depend on, to serve one adapter's
13
15
  // syntax, is the trade §22b already refused for `resolveClassName`. Instead this normalizes
14
16
  // through the adapter's OWN clsx boundary first (`normalizeSvelteClass`) and only scopes what
15
17
  // comes back as a class string; everything else — a resolved style object, `false`, a mixed array
@@ -17,13 +19,13 @@
17
19
  import { normalizeSvelteClass } from './class-value.js';
18
20
  import { scopeToken } from './scope-token.js';
19
21
  const CLASS_SEPARATOR = ' ';
20
- export function scopeSvelteClass(value, localNames, scopeId) {
22
+ export function scopeSvelteClass(value, names) {
21
23
  const normalized = normalizeSvelteClass(value);
22
24
  if (typeof normalized !== 'string')
23
25
  return normalized;
24
26
  return normalized
25
27
  .split(/\s+/)
26
28
  .filter(Boolean)
27
- .map(token => scopeToken(token, localNames, scopeId))
29
+ .map(token => scopeToken(token, names))
28
30
  .join(CLASS_SEPARATOR);
29
31
  }
@@ -58,7 +58,7 @@ function webOnlyConstructGuard() {
58
58
 
59
59
  // The `<style>` preprocessor, loaded the same lazy way and for the same reason. Unlike the guard
60
60
  // above (which only throws and hands the source back untouched), this one REWRITES the source —
61
- // it compiles the style block into registerStyles() output and scopes every `class` in the
61
+ // it compiles the style block into registerRules() output and scopes every `class` in the
62
62
  // component's own markup — so its returned `code` is what compile() must be given. See
63
63
  // src/preprocessor/scoped-styles.ts for why Svelte's own `result.css` cannot be used instead.
64
64
  let scopedStylesPromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/svelte",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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
  "license": "MIT",
6
6
  "repository": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "typescript": "~6.0.0",
66
- "@symbiote-native/components": "0.4.0",
67
- "@symbiote-native/css-parser": "0.3.0"
66
+ "@symbiote-native/components": "0.5.0",
67
+ "@symbiote-native/css-parser": "0.4.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@symbiote-native/engine": ">=0.1.7",
@@ -75,10 +75,10 @@
75
75
  "@vue/runtime-core": "^3.5.13",
76
76
  "react": "19.2.3",
77
77
  "svelte": "^5.56.0",
78
- "@symbiote-native/react": "0.3.0",
79
- "@symbiote-native/engine": "0.2.0",
80
- "@symbiote-native/test-utils": "0.1.6",
81
- "@symbiote-native/vue": "0.4.0"
78
+ "@symbiote-native/engine": "0.3.0",
79
+ "@symbiote-native/react": "0.4.0",
80
+ "@symbiote-native/test-utils": "0.2.0",
81
+ "@symbiote-native/vue": "0.5.0"
82
82
  },
83
83
  "scripts": {
84
84
  "typecheck": "tsc --build",