@symbiote-native/vue 0.3.8 → 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 (46) hide show
  1. package/README.md +6 -6
  2. package/build/bootstrap.js +1 -1
  3. package/build/components/flat-list/index.js +32 -39
  4. package/build/components/image-background.js +8 -19
  5. package/build/components/image.js +12 -21
  6. package/build/components/keyboard-avoiding-view.js +47 -31
  7. package/build/components/modal/index.d.ts +4 -4
  8. package/build/components/modal/index.js +5 -2
  9. package/build/components/pressable.d.ts +1 -1
  10. package/build/components/pressable.js +25 -32
  11. package/build/components/refresh-control.js +12 -18
  12. package/build/components/scroll-view/index.android.d.ts +1 -1
  13. package/build/components/scroll-view/index.android.js +24 -34
  14. package/build/components/scroll-view/index.ios.d.ts +1 -1
  15. package/build/components/scroll-view/index.ios.js +3 -1
  16. package/build/components/scroll-view/shared.js +93 -123
  17. package/build/components/scroll-view/sticky-header.js +59 -64
  18. package/build/components/section-list/index.js +16 -21
  19. package/build/components/switch/shared.js +29 -32
  20. package/build/components/text-input/index.js +36 -51
  21. package/build/components/touchable-native-feedback.js +3 -1
  22. package/build/components/touchable.d.ts +7 -1
  23. package/build/components/touchable.js +181 -33
  24. package/build/components/virtualized-list/index.js +57 -18
  25. package/build/components/virtualized-section-list/index.d.ts +5 -0
  26. package/build/components/virtualized-section-list/index.js +50 -29
  27. package/build/components.js +9 -6
  28. package/build/create-portal/index.d.ts +28 -0
  29. package/build/create-portal/index.js +42 -0
  30. package/build/host-instance/index.js +5 -5
  31. package/build/index.d.ts +9 -6
  32. package/build/index.js +18 -46
  33. package/build/modules/animated/create-animated-component.js +46 -101
  34. package/build/modules/animated/index.d.ts +2 -0
  35. package/build/modules/animated/index.js +26 -17
  36. package/build/modules/app-registry/index.js +11 -13
  37. package/build/modules/status-bar.js +12 -17
  38. package/build/render.d.ts +3 -1
  39. package/build/render.js +62 -1
  40. package/build/renderer/index.js +11 -6
  41. package/build/runtime-helpers/index.d.ts +44 -24
  42. package/build/runtime-helpers/index.js +116 -47
  43. package/build/utils/normalize-attrs.js +3 -1
  44. package/metro-css-parser.cjs +9 -14
  45. package/metro-vue-transformer.cjs +196 -178
  46. package/package.json +5 -5
@@ -1,55 +1,48 @@
1
- // Metro babel transformer that teaches the bundler to read Vue SFCs (.vue). Metro has no
2
- // Vue plugin (unplugin-vue ships vite/webpack/esbuild/rollup adapters, not Metro), so we do
3
- // the single-pass compile here: parse the SFC, compile <script setup> + <template> into one
4
- // component module, then hand the JS to RN's own babel transformer. This is the Metro twin
5
- // of what @vitejs/plugin-vue does for Vite; the 'vue'→runtime-core rewrite is needed because
6
- // a custom, non-DOM renderer needs the compiler helpers from @vue/runtime-core, not from
7
- // vue/runtime-dom.
1
+ // Metro babel transformer that teaches the bundler to read Vue SFCs (.vue). Metro has no Vue
2
+ // plugin (unplugin-vue only ships vite/webpack/esbuild/rollup adapters), so we do the
3
+ // single-pass compile here: parse the SFC, compile <script setup> + <template> into one
4
+ // component module, then hand the JS to RN's own babel transformer - the Metro twin of
5
+ // @vitejs/plugin-vue. Imports get retargeted from 'vue' to @symbiote-native/vue/runtime-helpers
6
+ // (not bare @vue/runtime-core) because that shim also supplies our own `vShow` - compiled
7
+ // v-show imports it by name, and only @vue/runtime-dom's DOM-based version exists otherwise.
8
8
  //
9
- // Retargeted at @symbiote-native/vue/runtime-helpers rather than bare @vue/runtime-core: that shim
10
- // re-exports runtime-core verbatim PLUS supplies our own `vShow` (compiled v-show imports it by
11
- // name, and only @vue/runtime-dom's DOM-based version exists otherwise).
12
- //
13
- // Ships as a package-level export (`@symbiote-native/vue/metro-vue-transformer`) rather than living
14
- // in each consuming app: a consumer's own metro.config.js just points babelTransformerPath at it.
9
+ // Ships as a package-level export (`@symbiote-native/vue/metro-vue-transformer`) rather than
10
+ // living in each consuming app: a consumer's own metro.config.js just points
11
+ // babelTransformerPath at it.
15
12
 
16
13
  const nodeFs = require('fs');
17
14
  const { parse, compileScript, registerTS } = require('@vue/compiler-sfc');
18
15
  const { createCompoundExpression } = require('@vue/compiler-core');
19
16
 
20
- // A bare-specifier type import (`import type { X } from '@symbiote-native/navigation/vue'`, as
21
- // opposed to a relative one) needs real node_modules resolution (package.json exports, pnpm
22
- // symlinks) to turn the specifier into a file path — compileScript's own `fs` option only reads
23
- // paths it's already given, it can't do that resolution. @vue/compiler-sfc's sanctioned hook for
24
- // this is `registerTS`, the same one @vitejs/plugin-vue and vue-tsc call: it hands the compiler a
25
- // lazy loader for the real `typescript` package, which resolves the specifier via
26
- // `ts.resolveModuleName` and self-supplies `ts.sys` as the fs fallback.
17
+ // A bare-specifier type import (`import type { X } from '@symbiote-native/navigation/vue'`) needs
18
+ // real node_modules resolution to turn the specifier into a file path - compileScript's own `fs`
19
+ // option only reads paths it's already given, it can't resolve. registerTS (the same hook
20
+ // @vitejs/plugin-vue and vue-tsc use) hands the compiler a lazy `typescript` loader that resolves
21
+ // via `ts.resolveModuleName` and self-supplies `ts.sys` as the fs fallback.
27
22
  registerTS(() => require('typescript'));
28
- // @symbiote-native/css-parser is a real dependency of THIS package, so requiring it directly here
29
- // (rather than through the ./metro-css-parser public subpath, which exists for CONSUMERS) resolves
30
- // straight from this package's own node_modules under pnpm.
23
+ // Required directly (not via the ./metro-css-parser public subpath, which exists for CONSUMERS)
24
+ // so it resolves from this package's own node_modules under pnpm.
31
25
  const {
32
26
  compile: compilePreprocessor,
33
27
  compileCssFile,
34
- globalClassNamesIn,
28
+ compileCssModule,
29
+ compileCssToRules,
30
+ compileScopedCss,
35
31
  hashFilePath,
36
32
  isStyleFile,
37
- kebabToCamel,
38
- parseCSS,
39
33
  resolveUpstreamTransformer,
40
34
  } = require('@symbiote-native/css-parser');
41
35
 
42
36
  const upstreamTransformer = resolveUpstreamTransformer();
43
37
 
44
38
  // compileScript needs file system access to resolve a type-only `defineProps<ISomeProps>()`
45
- // where ISomeProps is imported from another file — without it, Metro's worker process (a real
46
- // Node process, but one @vue/compiler-sfc doesn't auto-detect as Node unless the consumer calls
47
- // its `registerTS` hook) throws "No fs option provided ... in non-Node environment". Wiring
48
- // Node's own `fs` module straight through is simpler than registering the `typescript` package
49
- // as a loader (registerTS), and this file already runs in Node at Metro build time.
39
+ // imported from another file - without it, Metro's worker process (a real Node process, but one
40
+ // @vue/compiler-sfc doesn't auto-detect without registerTS) throws "No fs option provided ...
41
+ // in non-Node environment". Wiring Node's own `fs` straight through is simpler than a second
42
+ // registerTS-style loader.
50
43
  const compileScriptFs = {
51
- fileExists: (file) => nodeFs.existsSync(file),
52
- readFile: (file) => {
44
+ fileExists: file => nodeFs.existsSync(file),
45
+ readFile: file => {
53
46
  try {
54
47
  return nodeFs.readFileSync(file, 'utf-8');
55
48
  } catch {
@@ -58,47 +51,43 @@ const compileScriptFs = {
58
51
  },
59
52
  };
60
53
 
61
- // Rewrites a Vue template AST so every `class`/`:class` binding on an element resolves
62
- // against this file's scoped class names at the compiled call site, via @symbiote-native/engine's
63
- // scopeClassName(value, localNames, scopeId). This runs at the AST level rather than as a
64
- // raw-text regex: Vue itself merges a static `class=`
65
- // and a dynamic `:class=` on the same element into ONE codegen entry, and text substitution
66
- // can't reproduce that merge safely; letting Vue's own transformElement do the merge on our
67
- // already-rewritten nodes reuses that logic instead of reimplementing it).
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.
68
65
  //
69
- // A static class="foo bar" attribute is a plain AttributeNode (prop.type === 6): its value is
70
- // resolved to the final string directly here, at compile time — no runtime call needed for the
71
- // purely-static case, since every token is already known. Each token is normalized kebab->camel
72
- // FIRST (kebabToCamel) — a template may write class="section-label" (idiomatic CSS) instead of
73
- // class="sectionLabel"; the css-parser always registers the CAMEL form, and localNames (built
74
- // from that same registration) is camelCase-keyed, so the scoping check below only recognizes a
75
- // token once it is in that form. The emitted string is always camelCase, suffixed or not.
66
+ // A static class="foo bar" (AttributeNode, prop.type === 6) resolves to its final string here
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.
76
69
  //
77
- // A dynamic :class="expr" is a `bind` DirectiveNode (prop.type === 7) targeting the `class`
78
- // arg. Vue's own transformExpression has ALREADY run by the time our transform sees it (node
79
- // transforms are appended after the built-in preset, per @vue/compiler-core's TransformOptions
80
- // merge order), so prop.exp is usually a COMPOUND_EXPRESSION (type 8, a `children` array mixing
81
- // literal source chunks with resolved identifier nodes), not a plain string — a bare identifier
82
- // binding (`:class="dynamicClass"`) is the one case that stays a SIMPLE_EXPRESSION (type 4).
83
- // Either way, `createCompoundExpression` wraps the ORIGINAL exp node (whichever shape it is) as
84
- // a single child inside `scopeClassName(<original>, __localScopedClassNames, __scopeId)`, so
85
- // codegen emits the call with the original expression source reproduced unchanged inside it —
86
- // this needs no per-shape branching, and correctly defers even a fully opaque runtime value
87
- // (`:class="dynamicClass"`) to scopeClassName's own runtime token-matching, so there is no
88
- // unresolved gap for dynamic scoped classes.
89
- function createScopeClassNodeTransform(localNames, scopeId) {
70
+ // A dynamic :class="expr" (bind DirectiveNode, prop.type === 7) targeting `class`: by the time
71
+ // our transform runs, Vue's own transformExpression has already turned prop.exp into either a
72
+ // COMPOUND_EXPRESSION (type 8) or, for a bare identifier binding, a SIMPLE_EXPRESSION (type 4).
73
+ // createCompoundExpression wraps the original exp node as-is inside
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) {
90
78
  return function scopeClassNodeTransform(node) {
91
79
  if (node.type !== 1 /* NodeTypes.ELEMENT */) return;
92
80
 
93
81
  for (const prop of node.props) {
94
- 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
+ ) {
95
87
  prop.value.content = prop.value.content
96
88
  .split(/\s+/)
97
89
  .filter(Boolean)
98
- .map((token) => {
99
- const camelToken = kebabToCamel(token);
100
- return localNames.has(camelToken) ? `${camelToken}__${scopeId}` : camelToken;
101
- })
90
+ .map(token => renames.get(token) ?? token)
102
91
  .join(' ');
103
92
  continue;
104
93
  }
@@ -112,7 +101,7 @@ function createScopeClassNodeTransform(localNames, scopeId) {
112
101
  prop.exp
113
102
  ) {
114
103
  prop.exp = createCompoundExpression(
115
- ['__scopeClass(', prop.exp, ', __localScopedClassNames, __scopeId)'],
104
+ ['__scopeClass(', prop.exp, ', __scopedClassNames)'],
116
105
  prop.exp.loc,
117
106
  );
118
107
  }
@@ -120,14 +109,42 @@ function createScopeClassNodeTransform(localNames, scopeId) {
120
109
  };
121
110
  }
122
111
 
123
- // A short, stable id per file, used as the SFC scope id (compileScript wants one regardless of
124
- // whether the file has scoped styles) — Vue's own `data-v-hash` naming convention, built on
125
- // @symbiote-native/css-parser's shared hashFilePath so the algorithm isn't duplicated against the
126
- // standalone .module.css compiler's identical need (metro-css-module.ts).
112
+ // A short, stable id per file, used as the SFC scope id regardless of whether the file has
113
+ // scoped styles - built on css-parser's shared hashFilePath so the algorithm isn't duplicated
114
+ // against the standalone .module.css compiler's identical need.
127
115
  function scopeIdFor(filename) {
128
116
  return 'data-v-' + hashFilePath(filename);
129
117
  }
130
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
+
131
148
  // An SFC style block's `lang` attribute names a preprocessor language directly (`lang="scss"`),
132
149
  // unlike a standalone file, which is identified by its extension — so this is its own small
133
150
  // lookup rather than reusing detectLanguage(), which is extension-keyed.
@@ -139,15 +156,15 @@ const SFC_STYLE_LANG_TO_PREPROCESSOR = new Map([
139
156
  ]);
140
157
 
141
158
  // Reduces one <style> block down to plain CSS text. A lang-less or lang="css" block passes
142
- // through unchanged, exactly as before. Anything outside the four recognized preprocessor langs
143
- // (a typo, or a genuinely unsupported lang) still throws, same message as before this feature
144
- // existed.
159
+ // through unchanged. Anything outside the four recognized preprocessor langs throws.
145
160
  async function compileStyleBlockContent(style, filename) {
146
161
  if (style.lang == null || style.lang === 'css') return style.content;
147
162
 
148
163
  const preprocessorLang = SFC_STYLE_LANG_TO_PREPROCESSOR.get(style.lang);
149
164
  if (!preprocessorLang) {
150
- 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
+ );
151
168
  }
152
169
 
153
170
  // Sass' `.sass` indented syntax and `.scss` syntax share one compiler entry point that picks
@@ -162,172 +179,173 @@ async function compileStyleBlockContent(style, filename) {
162
179
  async function compileSfc(src, filename) {
163
180
  const { descriptor, errors } = parse(src, { filename });
164
181
  if (errors && errors.length > 0) {
165
- 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
+ );
166
185
  }
167
186
  if (descriptor.scriptSetup == null && descriptor.script == null) {
168
- 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
+ );
169
190
  }
170
191
 
171
192
  const scopeId = scopeIdFor(filename);
172
193
 
173
- // `descriptor.styles` is already parsed by @vue/compiler-sfc itself (one entry per <style>
174
- // block, `.content` pre-trimmed, `.scoped` already a plain boolean flag), so there's no need
175
- // to re-extract style blocks with a regex.
194
+ // descriptor.styles is already parsed by @vue/compiler-sfc (one entry per <style> block,
195
+ // content pre-trimmed, scoped as a plain boolean) - no need to re-extract with a regex.
176
196
  //
177
- // A scoped block's classes get their key SUFFIXED with this file's scopeId before
178
- // registration (`card` -> `card__data-v-xxxxxxxx`), so two components can each define their
179
- // own `.card` without colliding in the shared global registry — mirroring what Vue's own
180
- // `data-v-hash` attribute does for DOM targets, just as a name suffix instead of an attribute
181
- // selector (we have neither DOM nor attribute-selector matching). An unscoped block's classes
182
- // register exactly as before: no suffix, globally shared. `:global(...)` selectors inside a
183
- // scoped block are the one exception — @symbiote-native/css-parser already unwraps them to their
184
- // plain class name (`:global(.reset)` parses like `.reset`), so `globalClassNamesIn` re-scans
185
- // the block's own raw text to find which specific keys should be exempted from suffixing.
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):
186
199
  //
187
- // Cascade multiple blocks last-block-wins, same as CSS — this holds across scoped/unscoped
188
- // blocks too: only each block's OWN css-parser output is scoped independently, then merged.
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
189
204
  //
190
- // <style module> (CSS Modules) reuses this exact suffixing machinery instead of a separate
191
- // pipeline: `.card` still goes through parseCSS unchanged and still registers via the same
192
- // registerStyles() call, just under a suffixed key — the only new output is a plain name->
193
- // scopedName object (`$style` by default, or the block's `module="name"` value) emitted as a
194
- // preamble const, so `:class="$style.card"` passes the already-scoped string straight to
195
- // resolveClassName's existing exact-match path (no registry changes needed). Unlike `scoped`, a module block's classes are NEVER
196
- // auto-applied to a literal class="..." attribute — CSS Modules is opt-in per usage via
197
- // `$style.x`, so module classes are kept out of `localScopedNames` (the nodeTransform only
198
- // rewrites literal class strings for `scoped` blocks). The registry key gets an extra
199
- // `module` tag (`card__module__<scopeId>`, vs scoped's plain `card__<scopeId>`) so a file
200
- // that happens to mix `<style scoped>` and `<style module>` with the same class name can't
201
- // collide in the shared registry.
202
- const styles = {};
203
- const localScopedNames = new Set();
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.
208
+ //
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.
211
+ //
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();
204
217
  const cssModuleBindings = new Map();
205
218
 
206
219
  for (const style of descriptor.styles) {
207
- // Reduces a preprocessor block down to plain CSS text BEFORE any of the scoping logic
208
- // below runs — that logic is entirely language-agnostic, it only ever sees parseCSS's
209
- // plain-CSS output, same as it always did for a lang-less/lang="css" block. Anything other
210
- // than the four recognized preprocessor langs (a typo, or a genuinely unsupported lang)
211
- // still throws, unchanged from before.
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.
212
222
  const content = await compileStyleBlockContent(style, filename);
213
- const parsed = parseCSS(content, { filename });
214
223
 
215
224
  if (style.module) {
216
- const bindingName = typeof style.module === 'string' ? style.module : '$style';
217
- // Scanned against the COMPILED content, not style.content: :global(...) isn't native
218
- // SCSS/Less/Stylus syntax (each preprocessor passes an unrecognized selector through
219
- // unchanged), but scanning the compiler's actual output can't drift under nesting/
220
- // interpolation the way assuming source-and-output stay textually identical could.
221
- const exemptFromScope = globalClassNamesIn(content);
222
- const classMap = cssModuleBindings.get(bindingName) ?? {};
223
- for (const [className, props] of Object.entries(parsed)) {
224
- const isExempt = exemptFromScope.has(className);
225
- const registeredName = isExempt ? className : `${className}__module__${scopeId}`;
226
- classMap[className] = registeredName;
227
- styles[registeredName] = { ...styles[registeredName], ...props };
228
- }
229
- 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
+ });
230
233
  } else if (style.scoped) {
231
- const exemptFromScope = globalClassNamesIn(content);
232
- for (const [className, props] of Object.entries(parsed)) {
233
- const isExempt = exemptFromScope.has(className);
234
- const registeredName = isExempt ? className : `${className}__${scopeId}`;
235
- if (!isExempt) localScopedNames.add(className);
236
- styles[registeredName] = { ...styles[registeredName], ...props };
237
- }
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);
238
241
  } else {
239
- for (const [className, props] of Object.entries(parsed)) {
240
- styles[className] = { ...styles[className], ...props };
241
- }
242
+ appendRules(rules, compileCssToRules(content, { filename }).rules);
242
243
  }
243
244
  }
244
245
 
245
- // The nodeTransform rewrites every class/:class binding's compiled output to route through
246
- // scopeClassName() at the scoped names this file actually defines — skipped entirely (not
247
- // even passed to the compiler) when nothing in this file is scoped, so a .vue with only
248
- // unscoped/no styles compiles exactly as before, zero added runtime cost or behavior change.
246
+ // Skipped entirely (not even passed to the compiler) when nothing in this file is scoped, so
247
+ // a .vue with only unscoped/no styles compiles with zero added runtime cost.
249
248
  const templateOptions =
250
- localScopedNames.size > 0
249
+ scopedClassNames.size > 0
251
250
  ? {
252
251
  compilerOptions: {
253
- nodeTransforms: [createScopeClassNodeTransform(localScopedNames, scopeId)],
252
+ nodeTransforms: [createScopeClassNodeTransform(scopedClassNames)],
254
253
  },
255
254
  }
256
255
  : undefined;
257
256
 
258
257
  // inlineTemplate folds the <template> render fn into setup(): one module, one `export
259
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;
260
267
  const compiled = compileScript(descriptor, {
261
268
  id: scopeId,
262
269
  inlineTemplate: true,
263
270
  templateOptions,
264
271
  fs: compileScriptFs,
272
+ ...(hasCssModules ? { genDefaultAs: SFC_COMPONENT_VAR } : {}),
265
273
  });
266
- // Point every Vue import (the compiler's injected helpers AND the user's own
267
- // `import { ref } from 'vue'`) at the runtime-helpers shim, which re-exports the same
268
- // @vue/runtime-core singleton the @symbiote-native/vue adapter builds its custom renderer on, plus
269
- // our own directive implementations. No vue/runtime-dom in a native bundle.
270
- const code = compiled.content.replace(/from\s*(['"])vue\1/g, 'from "@symbiote-native/vue/runtime-helpers"');
274
+ // Retargets every Vue import (compiler-injected helpers AND the user's own `from 'vue'`) at
275
+ // the runtime-helpers shim - no vue/runtime-dom in a native bundle.
276
+ const code = compiled.content.replace(
277
+ /from\s*(['"])vue\1/g,
278
+ 'from "@symbiote-native/vue/runtime-helpers"',
279
+ );
271
280
 
272
- if (Object.keys(styles).length === 0) return code;
281
+ if (rules.length === 0 && !hasCssModules) return code;
273
282
 
274
- // Only a scoped file needs scopeClassName + its two per-file constants — the nodeTransform
275
- // above only ever emits calls to `__scopeClass`/`__localScopedClassNames`/`__scopeId` when
276
- // localScopedNames is non-empty, so these stay unimported/undeclared (and absent from the
277
- // bundle) for every non-scoped .vue file.
283
+ // Only a scoped file needs the runtime rename helper and its per-file map, so these stay
284
+ // unimported for every non-scoped .vue file.
278
285
  const engineImports =
279
- localScopedNames.size > 0 ? 'registerStyles, scopeClassName as __scopeClass' : 'registerStyles';
286
+ scopedClassNames.size > 0
287
+ ? 'registerRules, renameClassTokens as __scopeClass'
288
+ : 'registerRules';
280
289
 
281
- const preamble = [`registerStyles(${JSON.stringify(styles)});`];
282
- if (localScopedNames.size > 0) {
290
+ const preamble = [
291
+ `import { ${engineImports} } from '@symbiote-native/engine';`,
292
+ `registerRules(${serializeRules(rules)});`,
293
+ ];
294
+ if (scopedClassNames.size > 0) {
283
295
  preamble.push(
284
- `const __localScopedClassNames = new Set(${JSON.stringify([...localScopedNames])});`,
285
- `const __scopeId = ${JSON.stringify(scopeId)};`,
296
+ `const __scopedClassNames = ${JSON.stringify(Object.fromEntries(scopedClassNames))};`,
286
297
  );
287
298
  }
288
- // Each <style module> binding becomes a plain top-level const holding its name->scopedName
289
- // map. Placed before the compiled `export default {...}`, so it's just a closed-over module
290
- // scope variable inside setup() — same trick registerStyles/__scopeId already rely on — and
291
- // usable both from the inlined template (`:class="$style.card"`) and from <script setup> code
292
- // itself (`$style.card`), no extra wiring needed on either side.
299
+ // Each <style module> binding becomes a top-level const holding its name->scopedName map,
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.
293
302
  for (const [bindingName, classMap] of cssModuleBindings) {
294
303
  preamble.push(`const ${bindingName} = ${JSON.stringify(classMap)};`);
295
304
  }
296
305
 
297
- return [`import { ${engineImports} } from '@symbiote-native/engine';`, ...preamble, code].join('\n') + '\n';
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';
298
318
  }
299
319
 
300
320
  // Exported separately from `transform` so tests can assert on the compiled SFC output
301
- // (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.
302
322
  module.exports.compileSfc = compileSfc;
303
323
 
304
- // Async uniformly, including the `.vue` and plain-passthrough branches that never touch a
305
- // preprocessor: compileSfc() itself is async now (a scss/sass/less/stylus <style> block awaits
306
- // preprocessors.ts's compile()), and Metro's own metro-transform-worker already
307
- // `await transformer.transform(...)` before touching the result (confirmed by reading the
308
- // installed metro-transform-worker source), so returning a Promise here is a supported,
309
- // exercised shape, not a hack. A sync fast-path for
310
- // the no-preprocessor branches would fork this function into two shapes to save a single
311
- // microtask on a call that only ever runs at Metro build time, content-hash-cached — not worth
312
- // the duplication.
324
+ // Async uniformly, including branches that never touch a preprocessor: compileSfc() itself is
325
+ // async now (a scss/sass/less/stylus block awaits preprocessors.ts's compile()), and Metro's own
326
+ // metro-transform-worker already awaits transformer.transform(...), so this is a supported
327
+ // shape - not worth forking a sync fast-path to save one microtask on a build-time,
328
+ // content-hash-cached call.
313
329
  module.exports.transform = async function transform(params) {
314
330
  if (params.filename.endsWith('.vue')) {
315
331
  const code = await compileSfc(params.src, params.filename);
316
- // Re-label as .tsx so RN's transformer strips any TS from <script setup lang="ts"> and
317
- // processes the module exactly like app source. Metro tracks the real path separately.
332
+ // Re-label as .tsx so RN's transformer strips TS from <script setup lang="ts">. Metro
333
+ // tracks the real path separately.
318
334
  return upstreamTransformer.transform({
319
335
  ...params,
320
336
  src: code,
321
337
  filename: params.filename + '.tsx',
322
338
  });
323
339
  }
324
- // A standalone style file (as opposed to a `.vue` file's own inline <style> block above) —
325
- // the framework-agnostic path (core/css-parser's compileCssFile), usable from this example's
326
- // .vue files exactly like from any other adapter's example. isStyleFile recognizes
327
- // .css/.scss/.sass/.less/.styl/.stylus (+ each .module.* twin).
340
+ // A standalone style file (not a .vue file's inline <style> block) via the framework-agnostic
341
+ // compileCssFile path. isStyleFile recognizes .css/.scss/.sass/.less/.styl/.stylus (+ .module.* twins).
328
342
  if (isStyleFile(params.filename)) {
329
343
  const { code } = await compileCssFile(params.src, params.filename);
330
- return upstreamTransformer.transform({ ...params, src: code, filename: params.filename + '.js' });
344
+ return upstreamTransformer.transform({
345
+ ...params,
346
+ src: code,
347
+ filename: params.filename + '.js',
348
+ });
331
349
  }
332
350
  return upstreamTransformer.transform(params);
333
351
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/vue",
3
- "version": "0.3.8",
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.3.0",
50
- "@symbiote-native/css-parser": "0.2.3"
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.1.7",
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",