Skip to content

feat: support react-native imports in widget code - #270

Merged
V3RON merged 4 commits into
mainfrom
claude/react-native-imports-refactor-aqcd6y
Sep 9, 2026
Merged

feat: support react-native imports in widget code#270
V3RON merged 4 commits into
mainfrom
claude/react-native-imports-refactor-aqcd6y

Conversation

@V3RON

@V3RON V3RON commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Widget files can now import StyleSheet and Platform from react-native, so widget styles can live outside the element tree the same way they do everywhere else in an app. Previously any import from react-native in a widget file failed voltra apply and expo prebuild with Unexpected token 'typeof', and Dynamic Widgets rejected the import at bundle time.

Closes #199.

What is this?

Widget source is loaded in three places with different execution models: the CLI's apply pipeline, the Expo config plugins' prebuild prerender, and Metro when it bundles a Dynamic Widget for the device. Each had its own notion of what a widget file may import.

The two Node loaders were forked copies that had drifted apart — different resolvable extensions, different Babel configuration lookup, different fallback presets — and the client-package redirect from #200 existed in only one of them, so voltra apply still broke on @use-voltra/*-client imports that prebuild handled fine. Metro had a third answer: it rejected every react-native import outright.

The reported crash is a symptom of that. React Native's published entry point is untranspiled Flow, and bare imports bypassed Babel and fell through to plain Node require. But the deeper problem is the missing single answer: when the three environments disagree, a widget can prerender successfully and still fail to bundle, or render differently on device than the build-time placeholder it was prerendered from.

How does it work?

@use-voltra/compiler now owns widget module resolution, and all three environments consume it:

  • Policy — one function decides every bare import in widget code: pass it through, resolve something else instead, or reject it. Client packages resolve to their rendering package, react-native resolves to Voltra's shim, and deep react-native/... paths are rejected.
  • Loader — one Babel + VM implementation. The CLI and the Expo plugins are thin adapters that supply their own error type and warning sink. Reconciling the fork took the better half of each: the CLI's extension list, the plugin's redirect table and warning de-duplication, and Babel's own root-config discovery instead of either one's hand-rolled filename lookup.
  • Shim — the react-native surface widget code sees, served from the same file by both the Node loader and the Metro resolver, so build-time evaluation and on-device rendering cannot diverge.

The shim is an allowlist: StyleSheet (create as identity, flatten, compose, absoluteFill, hairlineWidth) and Platform (OS, select). Platform.OS is the platform the widget is being built for, which every call site already knew, so the loader takes it explicitly rather than guessing.

Rejection happens at two levels, because the two environments offer different interception points. The Node loader hands widget code a proxy over the shim, so any unimplemented symbol fails the moment the module is evaluated. Metro resolves the shim file directly and has no such hook, so the shim additionally names the React Native exports widget code is most likely to reach for and exports each as a stub that throws on use. Importing a stub is harmless; rendering, calling, or reading a property off it is not.

Decisions and their reasoning are recorded in ADR 0002.

Why is this useful?

Keeping styles out of the element tree is ordinary React Native practice, and until now it broke the build with an error that pointed at nothing a user could act on. Beyond that specific fix, adding to the widget-visible surface now means changing one shim that serves all three environments at once — there is no longer a way to fix one and forget the others.

The allowlist means a react-native API that would silently misbehave in a widget fails loudly instead. That is a deliberate trade: an explicit error is cheaper than a widget that renders wrong on someone's Home Screen.

Two pre-existing bugs fall out of the consolidation. voltra apply now applies the client-package redirect that prebuild already had, and it now honours Babel configurations it previously ignored — a project keeping its setup in babel.config.json or babel.config.ts silently lost its plugins when widgets were transpiled.


Validation. pnpm build, typecheck, lint, test (25 tasks) and format:js:check all pass. 19 new tests in @use-voltra/compiler cover the loader, the policy, and the shim entry points Metro resolves. I also ran the loader against the real example/ project end to end: AndroidClientDemoWidget.tsx loads and renders, StyleSheet.create and Platform.OS resolve per platform, and unsupported symbols fail with the intended messages.

Swift and Kotlin tests were not run — no toolchain was available in the environment this was developed in. No native code is touched by this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wxpgc46zjDeyGzkUpL1TjK


Generated by Claude Code

Widget source is loaded in three places with different execution models:
`voltra apply`, the Expo config plugins' prebuild prerender, and Metro when
it bundles a Dynamic Widget for the device. Each had its own notion of what a
widget file may import. The two Node loaders were forked copies that had
drifted apart, and the client-package redirect existed in only one of them;
Metro rejected every `react-native` import outright. `import { StyleSheet }
from 'react-native'` therefore crashed the apply pipeline with
`Unexpected token 'typeof'`.

Move widget module resolution into @use-voltra/compiler so all three consume
one contract: a policy that decides every bare import, one Babel + VM loader
the CLI and the plugins adapt, and a `react-native` shim that both the loader
and the Metro resolver serve from the same file. The shim is an allowlist —
StyleSheet and Platform — and every other symbol fails the build naming
itself rather than becoming undefined at render time.

`Platform.OS` is the platform being built for, which every call site already
knows, so the loader takes it explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wxpgc46zjDeyGzkUpL1TjK
Six defects found reviewing the previous commit:

- The compiler's ESM entry stopped loading. Its relative re-exports were
  emitted extensionless under `build/esm/package.json`'s `"type": "module"`;
  the package worked before only because it was a single file with no
  relative imports. Use explicit `.js` specifiers, as `@use-voltra/core`
  already does.
- The unsupported-symbol guard lived only in the Node VM loader, so on
  device `Animated` and friends read as `undefined` rather than failing —
  contradicting the ADR. The shim now names the React Native exports widget
  code is most likely to reach for and exports each as a stub that throws on
  use, so Metro bundles fail loudly too.
- `configFile: false` plus a three-name filename list dropped Babel's own
  root-config discovery, so `babel.config.json` and `.ts` projects silently
  lost their plugins in `voltra apply`. Ask Babel via `loadPartialConfig`
  instead of guessing filenames.
- The fallback preset order changed Expo prebuild's preset from
  `babel-preset-expo` to `@react-native/babel-preset`. Prefer the Expo
  preset, which wraps the other, and cover the fallback path with a test.
- Metro dropped the client-package redirect warning both Node loaders emit.
- The changeset did not mention the expo-plugin signature changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wxpgc46zjDeyGzkUpL1TjK
@Angelk90

Angelk90 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@V3RON :
Do you think it might be useful to be able to also import: PixelRatio,useColorScheme

…mports-refactor-aqcd6y

# Conflicts:
#	docs/adr/README.md
expo-plugin and the cli package now import runtime helpers from
@use-voltra/compiler, but their tsconfig.typecheck.json files lacked a
paths mapping to its source, so tsc could only resolve the package
through its build output (missing on a fresh checkout/CI run).
@V3RON
V3RON merged commit 58235a7 into main Sep 9, 2026
14 checks passed
@V3RON
V3RON deleted the claude/react-native-imports-refactor-aqcd6y branch September 9, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to use StyleSheet

3 participants