@symbiote-native/vue 0.4.0 → 0.5.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 (42) hide show
  1. package/README.md +6 -6
  2. package/build/bootstrap.js +1 -1
  3. package/build/components/flat-list/index.js +14 -5
  4. package/build/components/image-background.js +4 -5
  5. package/build/components/image.js +3 -1
  6. package/build/components/keyboard-avoiding-view.js +39 -14
  7. package/build/components/modal/index.js +5 -2
  8. package/build/components/pressable.d.ts +1 -1
  9. package/build/components/pressable.js +1 -1
  10. package/build/components/scroll-view/index.android.d.ts +1 -1
  11. package/build/components/scroll-view/index.android.js +3 -1
  12. package/build/components/scroll-view/index.ios.d.ts +1 -1
  13. package/build/components/scroll-view/index.ios.js +3 -1
  14. package/build/components/scroll-view/shared.js +33 -13
  15. package/build/components/scroll-view/sticky-header.js +18 -5
  16. package/build/components/section-list/index.js +4 -2
  17. package/build/components/switch/shared.js +12 -3
  18. package/build/components/text-input/index.js +7 -2
  19. package/build/components/touchable-native-feedback.js +3 -1
  20. package/build/components/touchable.d.ts +7 -1
  21. package/build/components/touchable.js +181 -33
  22. package/build/components/virtualized-list/index.js +42 -20
  23. package/build/components/virtualized-section-list/index.d.ts +5 -0
  24. package/build/components/virtualized-section-list/index.js +33 -4
  25. package/build/components.js +8 -3
  26. package/build/create-portal/index.d.ts +28 -0
  27. package/build/create-portal/index.js +42 -0
  28. package/build/host-instance/index.js +3 -1
  29. package/build/index.d.ts +8 -6
  30. package/build/index.js +9 -3
  31. package/build/modules/animated/create-animated-component.js +12 -4
  32. package/build/modules/animated/index.d.ts +2 -0
  33. package/build/modules/animated/index.js +18 -5
  34. package/build/modules/app-registry/index.js +4 -2
  35. package/build/modules/status-bar.js +4 -2
  36. package/build/render.d.ts +3 -1
  37. package/build/render.js +62 -1
  38. package/build/runtime-helpers/index.d.ts +44 -24
  39. package/build/runtime-helpers/index.js +107 -28
  40. package/build/utils/normalize-attrs.js +3 -1
  41. package/metro-vue-transformer.cjs +138 -97
  42. package/package.json +5 -5
@@ -23,15 +23,13 @@ registerTS(() => require('typescript'));
23
23
  // Required directly (not via the ./metro-css-parser public subpath, which exists for CONSUMERS)
24
24
  // so it resolves from this package's own node_modules under pnpm.
25
25
  const {
26
- classTokensIn,
27
26
  compile: compilePreprocessor,
28
27
  compileCssFile,
29
- globalClassNamesIn,
30
- globalClassTokensIn,
28
+ compileCssModule,
29
+ compileCssToRules,
30
+ compileScopedCss,
31
31
  hashFilePath,
32
32
  isStyleFile,
33
- kebabToCamel,
34
- parseCSS,
35
33
  resolveUpstreamTransformer,
36
34
  } = require('@symbiote-native/css-parser');
37
35
 
@@ -53,36 +51,43 @@ const compileScriptFs = {
53
51
  },
54
52
  };
55
53
 
56
- // Rewrites a Vue template AST so every `class`/`:class` binding resolves against this file's
57
- // scoped class names via @symbiote-native/engine's scopeClassName(value, localNames, scopeId).
58
- // AST-level, not a raw-text regex: Vue merges a static class= and a dynamic :class= on the same
59
- // element into ONE codegen entry, and text substitution can't reproduce that merge safely -
60
- // letting Vue's own transformElement do the merge on our rewritten nodes reuses that logic.
54
+ // Rewrites a Vue template AST so every `class`/`:class` binding names a class by the name
55
+ // lightningcss RENAMED it to. AST-level, not a raw-text regex: Vue merges a static class= and a
56
+ // dynamic :class= on the same element into ONE codegen entry, and text substitution can't
57
+ // reproduce that merge safely - letting Vue's own transformElement do the merge on our rewritten
58
+ // nodes reuses that logic.
59
+ //
60
+ // `renames` is compileScopedCss's own name map (authored name -> renamed name), NOT a set of
61
+ // names this file re-suffixes. That is the point of the migration: the rewriter and the style
62
+ // compiler used to derive the scoped name independently, so any disagreement between them was
63
+ // silent. Now there is one source and this half only reads it. One entry per class, keyed as
64
+ // AUTHORED — `class="section-label"` is the lookup, `sectionLabel` is not a second spelling of it.
61
65
  //
62
66
  // A static class="foo bar" (AttributeNode, prop.type === 6) resolves to its final string here
63
- // at compile time - no runtime call needed. Each token is normalized kebab->camel FIRST since
64
- // css-parser always registers the camel form and localNames is camelCase-keyed.
67
+ // at compile time - no runtime call needed. A token the map does not carry belongs to another
68
+ // file (App.css, a `:global()` escape hatch, a parent's class) and passes through untouched.
65
69
  //
66
70
  // A dynamic :class="expr" (bind DirectiveNode, prop.type === 7) targeting `class`: by the time
67
71
  // our transform runs, Vue's own transformExpression has already turned prop.exp into either a
68
72
  // COMPOUND_EXPRESSION (type 8) or, for a bare identifier binding, a SIMPLE_EXPRESSION (type 4).
69
73
  // createCompoundExpression wraps the original exp node as-is inside
70
- // scopeClassName(<original>, __localScopedClassNames, __scopeId), so codegen reproduces the
71
- // original expression unchanged - no per-shape branching needed, and a fully opaque runtime
72
- // value (`:class="dynamicClass"`) still defers correctly to scopeClassName's own token matching.
73
- function createScopeClassNodeTransform(localNames, scopeId) {
74
+ // renameClassTokens(<original>, __scopedClassNames), so codegen reproduces the original
75
+ // expression unchanged - no per-shape branching needed, and a fully opaque runtime value
76
+ // (`:class="dynamicClass"`) still resolves against the same map at runtime.
77
+ function createScopeClassNodeTransform(renames) {
74
78
  return function scopeClassNodeTransform(node) {
75
79
  if (node.type !== 1 /* NodeTypes.ELEMENT */) return;
76
80
 
77
81
  for (const prop of node.props) {
78
- if (prop.type === 6 /* NodeTypes.ATTRIBUTE */ && prop.name === 'class' && prop.value) {
82
+ if (
83
+ prop.type === 6 /* NodeTypes.ATTRIBUTE */ &&
84
+ prop.name === 'class' &&
85
+ prop.value
86
+ ) {
79
87
  prop.value.content = prop.value.content
80
88
  .split(/\s+/)
81
89
  .filter(Boolean)
82
- .map(token => {
83
- const camelToken = kebabToCamel(token);
84
- return localNames.has(camelToken) ? `${camelToken}__${scopeId}` : camelToken;
85
- })
90
+ .map(token => renames.get(token) ?? token)
86
91
  .join(' ');
87
92
  continue;
88
93
  }
@@ -96,7 +101,7 @@ function createScopeClassNodeTransform(localNames, scopeId) {
96
101
  prop.exp
97
102
  ) {
98
103
  prop.exp = createCompoundExpression(
99
- ['__scopeClass(', prop.exp, ', __localScopedClassNames, __scopeId)'],
104
+ ['__scopeClass(', prop.exp, ', __scopedClassNames)'],
100
105
  prop.exp.loc,
101
106
  );
102
107
  }
@@ -111,6 +116,35 @@ function scopeIdFor(filename) {
111
116
  return 'data-v-' + hashFilePath(filename);
112
117
  }
113
118
 
119
+ // The local the compiled component lands in when a <style module> block needs __cssModules hung
120
+ // off it (compileScript's genDefaultAs). Same role as @vitejs/plugin-vue's own `_sfc_main`.
121
+ const SFC_COMPONENT_VAR = '__sfc__';
122
+
123
+ // Blocks CONCATENATE - each is compiled on its own, so every block's rules restart at order 0 and
124
+ // a later block would tie with an earlier one on the cascade's source-order tie-break, silently
125
+ // reordering equally-specific rules. Renumbering on append gives the file one monotonic sequence,
126
+ // which is what a single stylesheet holding the concatenated blocks would have produced.
127
+ function appendRules(target, rules) {
128
+ for (const rule of rules) {
129
+ target.push({ ...rule, order: target.length });
130
+ }
131
+ }
132
+
133
+ // `combinators` is compile-time-only - the registry matches a rule by token SUBSET and never reads
134
+ // it - so it is stripped rather than shipped in every app bundle. Twin of css-parser's own
135
+ // serializeRules (core/css-parser/src/metro-css-module/index.ts), which does this for a standalone
136
+ // style file.
137
+ function serializeRules(rules) {
138
+ return JSON.stringify(
139
+ rules.map(({ tokens, specificity, order, style }) => ({
140
+ tokens,
141
+ specificity,
142
+ order,
143
+ style,
144
+ })),
145
+ );
146
+ }
147
+
114
148
  // An SFC style block's `lang` attribute names a preprocessor language directly (`lang="scss"`),
115
149
  // unlike a standalone file, which is identified by its extension — so this is its own small
116
150
  // lookup rather than reusing detectLanguage(), which is extension-keyed.
@@ -128,7 +162,9 @@ async function compileStyleBlockContent(style, filename) {
128
162
 
129
163
  const preprocessorLang = SFC_STYLE_LANG_TO_PREPROCESSOR.get(style.lang);
130
164
  if (!preprocessorLang) {
131
- throw new Error(`SFC style lang="${style.lang}" not supported yet — plain CSS only`);
165
+ throw new Error(
166
+ `SFC style lang="${style.lang}" not supported yet — plain CSS only`,
167
+ );
132
168
  }
133
169
 
134
170
  // Sass' `.sass` indented syntax and `.scss` syntax share one compiler entry point that picks
@@ -143,10 +179,14 @@ async function compileStyleBlockContent(style, filename) {
143
179
  async function compileSfc(src, filename) {
144
180
  const { descriptor, errors } = parse(src, { filename });
145
181
  if (errors && errors.length > 0) {
146
- throw new Error(`Vue SFC parse error in ${filename}:\n${errors.map(String).join('\n')}`);
182
+ throw new Error(
183
+ `Vue SFC parse error in ${filename}:\n${errors.map(String).join('\n')}`,
184
+ );
147
185
  }
148
186
  if (descriptor.scriptSetup == null && descriptor.script == null) {
149
- throw new Error(`Vue SFC ${filename} has no <script> / <script setup> block`);
187
+ throw new Error(
188
+ `Vue SFC ${filename} has no <script> / <script setup> block`,
189
+ );
150
190
  }
151
191
 
152
192
  const scopeId = scopeIdFor(filename);
@@ -154,93 +194,82 @@ async function compileSfc(src, filename) {
154
194
  // descriptor.styles is already parsed by @vue/compiler-sfc (one entry per <style> block,
155
195
  // content pre-trimmed, scoped as a plain boolean) - no need to re-extract with a regex.
156
196
  //
157
- // A scoped block's classes get their key SUFFIXED with this file's scopeId (`card` ->
158
- // `card__data-v-xxxxxxxx`) so two components can each define `.card` without colliding in the
159
- // shared global registry - our name-suffix equivalent of Vue's `data-v-hash` attribute (we
160
- // have no DOM/attribute-selector matching). `:global(...)` selectors are exempted from
161
- // suffixing; globalClassNamesIn re-walks the block's selectors to find which keys to exempt.
197
+ // Both scoped forms are ONE mechanism, lightningcss's CSS-Modules renaming, differing only in
198
+ // the pattern string (core/css-parser/src/scoped-classes.ts):
199
+ //
200
+ // <style scoped> [local]__data-v-<hash> compileScopedCss - `.card` -> `card__data-v-h`
201
+ // <style module> [local]__module__<hash> compileCssModule - the same call a standalone
202
+ // .module.css takes, so the two cannot diverge
203
+ // <style> not renamed at all compileCssToRules, classes register globally
204
+ //
205
+ // Renaming is our equivalent of Vue's `data-v-hash` ATTRIBUTE (we have no DOM and no
206
+ // attribute-selector matching, so a scope can only be expressed in the name), and `:global(...)`
207
+ // needs no handling here at all: a name lightningcss did not rename is global by definition.
162
208
  //
163
- // Multiple blocks cascade last-block-wins, same as CSS, after each block is scoped independently.
209
+ // Multiple blocks cascade last-block-wins, same as CSS, after each block is scoped
210
+ // independently — carried by the rules' `order`, which appendRules renumbers across blocks.
164
211
  //
165
- // <style module> (CSS Modules) reuses this same suffixing machinery: `.card` still goes
166
- // through parseCSS and registerStyles unchanged, just under a suffixed key - the only new
167
- // output is a name->scopedName object ($style by default) emitted as a preamble const, so
168
- // `:class="$style.card"` passes the already-scoped string straight to resolveClassName's
169
- // exact-match path. Unlike `scoped`, module classes are NEVER auto-applied to a literal
170
- // class="..." (opt-in via $style.x only), so they're kept out of localScopedNames. The
171
- // registry key gets an extra `module` tag (`card__module__<scopeId>` vs scoped's
172
- // `card__<scopeId>`) so a file mixing both kinds can't collide.
173
- const styles = {};
174
- const localScopedNames = new Set();
212
+ // A module block's classes are NEVER auto-applied to a literal class="..." (opt-in via $style.x
213
+ // only), so they stay out of the template rewriter's name map; its output is the name->scopedName
214
+ // map instead, emitted as a preamble const AND attached to the component as __cssModules.
215
+ const rules = [];
216
+ const scopedClassNames = new Map();
175
217
  const cssModuleBindings = new Map();
176
218
 
177
219
  for (const style of descriptor.styles) {
178
- // Reduces a preprocessor block to plain CSS BEFORE the scoping logic below runs - that
179
- // logic is language-agnostic, it only ever sees parseCSS's plain-CSS output.
220
+ // Reduces a preprocessor block to plain CSS BEFORE the renaming below runs - the renaming is
221
+ // language-agnostic, it only ever sees plain CSS.
180
222
  const content = await compileStyleBlockContent(style, filename);
181
- const parsed = parseCSS(content, { filename });
182
223
 
183
224
  if (style.module) {
184
- const bindingName = typeof style.module === 'string' ? style.module : '$style';
185
- // Scanned against the COMPILED content, not style.content, so it can't drift under
186
- // preprocessor nesting/interpolation.
187
- const exemptFromScope = globalClassNamesIn(content);
188
- const classMap = cssModuleBindings.get(bindingName) ?? {};
189
- for (const [className, props] of Object.entries(parsed)) {
190
- const isExempt = exemptFromScope.has(className);
191
- const registeredName = isExempt ? className : `${className}__module__${scopeId}`;
192
- classMap[className] = registeredName;
193
- styles[registeredName] = { ...styles[registeredName], ...props };
194
- }
195
- cssModuleBindings.set(bindingName, classMap);
225
+ const bindingName =
226
+ typeof style.module === 'string' ? style.module : '$style';
227
+ const compiled = compileCssModule(content, filename);
228
+ appendRules(rules, compiled.rules);
229
+ cssModuleBindings.set(bindingName, {
230
+ ...cssModuleBindings.get(bindingName),
231
+ ...compiled.classMap,
232
+ });
196
233
  } else if (style.scoped) {
197
- const exemptFromScope = globalClassNamesIn(content);
198
- // A compound/descendant selector registers under ONE collapsed key (`.card.big` ->
199
- // `cardBig`) that never appears in the template - the template writes
200
- // `class="card big"` - so the nodeTransform must recognize the individual TOKENS, not
201
- // just the collapsed key.
202
- const tokensByName = classTokensIn(content, { filename });
203
- // ...and a token out of a `:global(...)` payload is the one exception to that: in
204
- // `.card :global(.reset)` the KEY (`cardReset`) is this file's own, because `.card` is,
205
- // but `reset` was written precisely to name markup this file does not own. Suffixing it
206
- // along with the rest of its chain scope-mangles the escape hatch into matching nothing.
207
- const globalTokens = globalClassTokensIn(content, { filename });
208
- for (const [className, props] of Object.entries(parsed)) {
209
- const isExempt = exemptFromScope.has(className);
210
- const registeredName = isExempt ? className : `${className}__${scopeId}`;
211
- if (!isExempt) {
212
- localScopedNames.add(className);
213
- for (const token of tokensByName.get(className) ?? []) {
214
- if (!globalTokens.has(token)) localScopedNames.add(token);
215
- }
216
- }
217
- styles[registeredName] = { ...styles[registeredName], ...props };
218
- }
234
+ const compiled = compileScopedCss(content, {
235
+ filename,
236
+ pattern: `[local]__${scopeId}`,
237
+ });
238
+ appendRules(rules, compiled.rules);
239
+ for (const [name, renamed] of compiled.names)
240
+ scopedClassNames.set(name, renamed);
219
241
  } else {
220
- for (const [className, props] of Object.entries(parsed)) {
221
- styles[className] = { ...styles[className], ...props };
222
- }
242
+ appendRules(rules, compileCssToRules(content, { filename }).rules);
223
243
  }
224
244
  }
225
245
 
226
246
  // Skipped entirely (not even passed to the compiler) when nothing in this file is scoped, so
227
247
  // a .vue with only unscoped/no styles compiles with zero added runtime cost.
228
248
  const templateOptions =
229
- localScopedNames.size > 0
249
+ scopedClassNames.size > 0
230
250
  ? {
231
251
  compilerOptions: {
232
- nodeTransforms: [createScopeClassNodeTransform(localScopedNames, scopeId)],
252
+ nodeTransforms: [createScopeClassNodeTransform(scopedClassNames)],
233
253
  },
234
254
  }
235
255
  : undefined;
236
256
 
237
257
  // inlineTemplate folds the <template> render fn into setup(): one module, one `export
238
258
  // default`. Only valid with <script setup>, which the canary uses.
259
+ //
260
+ // genDefaultAs turns that `export default {...}` into `const __sfc__ = {...}`, so a <style
261
+ // module> block can hang __cssModules off the component OPTIONS object before the module
262
+ // exports it (the same thing @vitejs/plugin-vue does). Vue resolves a template's `$style` /
263
+ // `classes` off `instance.type.__cssModules`, NOT off module scope, so without this the
264
+ // emitted const is unreachable from the template and `$style.card` throws at render. Only
265
+ // requested when a module block exists, so every other .vue file keeps identical output.
266
+ const hasCssModules = cssModuleBindings.size > 0;
239
267
  const compiled = compileScript(descriptor, {
240
268
  id: scopeId,
241
269
  inlineTemplate: true,
242
270
  templateOptions,
243
271
  fs: compileScriptFs,
272
+ ...(hasCssModules ? { genDefaultAs: SFC_COMPONENT_VAR } : {}),
244
273
  });
245
274
  // Retargets every Vue import (compiler-injected helpers AND the user's own `from 'vue'`) at
246
275
  // the runtime-helpers shim - no vue/runtime-dom in a native bundle.
@@ -249,35 +278,47 @@ async function compileSfc(src, filename) {
249
278
  'from "@symbiote-native/vue/runtime-helpers"',
250
279
  );
251
280
 
252
- if (Object.keys(styles).length === 0) return code;
281
+ if (rules.length === 0 && !hasCssModules) return code;
253
282
 
254
- // Only a scoped file needs scopeClassName + its two per-file constants, so these stay
283
+ // Only a scoped file needs the runtime rename helper and its per-file map, so these stay
255
284
  // unimported for every non-scoped .vue file.
256
285
  const engineImports =
257
- localScopedNames.size > 0 ? 'registerStyles, scopeClassName as __scopeClass' : 'registerStyles';
286
+ scopedClassNames.size > 0
287
+ ? 'registerRules, renameClassTokens as __scopeClass'
288
+ : 'registerRules';
258
289
 
259
- const preamble = [`registerStyles(${JSON.stringify(styles)});`];
260
- if (localScopedNames.size > 0) {
290
+ const preamble = [
291
+ `import { ${engineImports} } from '@symbiote-native/engine';`,
292
+ `registerRules(${serializeRules(rules)});`,
293
+ ];
294
+ if (scopedClassNames.size > 0) {
261
295
  preamble.push(
262
- `const __localScopedClassNames = new Set(${JSON.stringify([...localScopedNames])});`,
263
- `const __scopeId = ${JSON.stringify(scopeId)};`,
296
+ `const __scopedClassNames = ${JSON.stringify(Object.fromEntries(scopedClassNames))};`,
264
297
  );
265
298
  }
266
299
  // Each <style module> binding becomes a top-level const holding its name->scopedName map,
267
- // placed before the compiled `export default {...}` so it's a closed-over module-scope
268
- // variable usable both from the inlined template and from <script setup> code itself.
300
+ // placed before the component so it's a closed-over module-scope variable usable from
301
+ // <script setup> code itself; the __cssModules tail below is what makes the TEMPLATE see it.
269
302
  for (const [bindingName, classMap] of cssModuleBindings) {
270
303
  preamble.push(`const ${bindingName} = ${JSON.stringify(classMap)};`);
271
304
  }
272
305
 
273
- return (
274
- [`import { ${engineImports} } from '@symbiote-native/engine';`, ...preamble, code].join('\n') +
275
- '\n'
276
- );
306
+ const parts = [...preamble, code];
307
+ if (hasCssModules) {
308
+ const bindings = [...cssModuleBindings.keys()]
309
+ .map(name => `${JSON.stringify(name)}: ${name}`)
310
+ .join(', ');
311
+ parts.push(
312
+ `${SFC_COMPONENT_VAR}.__cssModules = { ${bindings} };`,
313
+ `export default ${SFC_COMPONENT_VAR};`,
314
+ );
315
+ }
316
+
317
+ return parts.join('\n') + '\n';
277
318
  }
278
319
 
279
320
  // Exported separately from `transform` so tests can assert on the compiled SFC output
280
- // (imports, injected `registerStyles` call) without driving the full upstream RN Babel preset.
321
+ // (imports, injected `registerRules` call) without driving the full upstream RN Babel preset.
281
322
  module.exports.compileSfc = compileSfc;
282
323
 
283
324
  // Async uniformly, including branches that never touch a preprocessor: compileSfc() itself is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/vue",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "SymbioteNative's Vue adapter — a @vue/runtime-core createRenderer driving real native iOS/Android views through the same engine as the React adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,8 +46,8 @@
46
46
  "@vue/compiler-sfc": "^3.5.13",
47
47
  "@vue/runtime-core": "^3.5.13",
48
48
  "typescript": "~6.0.0",
49
- "@symbiote-native/components": "0.4.0",
50
- "@symbiote-native/css-parser": "0.3.0"
49
+ "@symbiote-native/components": "0.5.0",
50
+ "@symbiote-native/css-parser": "0.4.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@symbiote-native/engine": ">=0.1.7",
@@ -55,8 +55,8 @@
55
55
  "react-native": ">=0.86"
56
56
  },
57
57
  "devDependencies": {
58
- "@symbiote-native/engine": "0.2.0",
59
- "@symbiote-native/test-utils": "0.1.6"
58
+ "@symbiote-native/engine": "0.3.0",
59
+ "@symbiote-native/test-utils": "0.2.0"
60
60
  },
61
61
  "scripts": {
62
62
  "typecheck": "tsc --build",