Skip to content

feat(hooks): async instance creation via useViewModelInstance({ async: true })#304

Merged
mfazekas merged 19 commits into
feat/rive-ios-experimentalfrom
feat/use-viewmodel-instance-async
Jul 10, 2026
Merged

feat(hooks): async instance creation via useViewModelInstance({ async: true })#304
mfazekas merged 19 commits into
feat/rive-ios-experimentalfrom
feat/use-viewmodel-instance-async

Conversation

@mfazekas

@mfazekas mfazekas commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an async: true param to useViewModelInstance that creates the ViewModelInstance via the non-deprecated *Async runtime APIs, resolving off the JS thread. The result gains an isLoading state (the sync path reports it too, so both modes share one result shape). The non-async overloads are deprecated per-overload and async: true becomes the default in the next major.

Why

The sync creation path uses @deprecated runtime APIs that block the JS thread. Keeping a single hook name (instead of a separate useViewModelInstanceAsync) makes migration a param flip, keeps parity with @rive-app/react, and lets the default change at the next major without a rename.

Notes

  • async selects between two hook implementations and must stay constant for a component's lifetime (documented on the param; a dev-mode console.error explains a flip before React's hooks-order invariant fires).
  • Deprecation coverage is total: any call not provably async: true — including params objects whose async widened to boolean, or values of the exported *Params types — resolves to a deprecated overload. The message includes the fix: re-pin with { ...params, async: true }.
  • Overload/deprecation resolution is regression-pinned with tsd (yarn typetest, src/__tests__/useViewModelInstance.test-d.tsexpectDeprecated/expectNotDeprecated/expectError/expectType). The wiring mirrors feat: type-safe .riv schema system #294's verbatim (same dep, script, config) so whichever PR merges second reconciles trivially; this PR additionally gates the script in the CI lint job.
  • In both modes, a null source settles terminally while undefined keeps loading — mirroring useRiveFile and useRive.
  • Companion fixes: friendly artboard/instance not-found errors with the native rejection preserved as cause (and swallowed native failures logged); the new backends resolve null for a VM-less default artboard instead of surfacing a raw error (issue Android [RIVE] HybridRiveView afterUpdate No default ViewModel found for artboard error #189 fixture); a RiveViewRef source polls briefly for the view's auto-bound instance instead of settling a one-shot null; the intermediate ViewModel wrapper is disposed after creation; on source change the hook resets during render so no frame ever pairs the new source with the previous (about-to-be-disposed) instance; required failures throw actionable messages (a null source points at the upstream hook's error).
  • Legacy Android thread-safety: getViewModelInstance() no longer traverses controller state from the JS thread — it reads a main-thread-maintained snapshot (refreshed eagerly when binding changes), avoiding both the iOS Crash: Occasional crashes #297 race class and a JS↔main runBlocking deadlock hazard.
  • CI: the expo57 harness now runs the full shared suite (example/__tests__ had been silently dropped — coverage went from 4 to 26 suites) with attempt budgets resized to fit, and crash reports are collected on the legacy iOS job too.
  • Example screens migrated to { async: true }.

Breaking changes

  • useRive().riveViewRef is undefined (not null) until the view is ready; null now means failed/detached. Migrate riveViewRef !== null checks to != null (or riveViewRef?.method()), and widen any explicit RiveViewRef | null annotations.
  • On the sync (non-async, deprecated) path a null source now settles to { instance: null, isLoading: false } instead of a perpetual instance: undefined, and required: true throws for it — matching the async semantics.
  • UseViewModelInstanceResult gained an isLoading field (additive for consumers, breaking only for exact-shape type assertions).
  • Legacy backends: the *Async runtime lookups now execute on the main thread — "async" means "doesn't block the JS thread", never "parallel with rendering".

Verify

yarn tsc, yarn lint, yarn typetest (tsd), yarn jest (86 passed, incl. committed-frame, overload-compat, and async-flip-guard regression tests). On-device: the full restored harness suite (26 suites / 204 tests) green on all four CI jobs (iOS + Android × experimental + legacy); the legacy cause divergence, the VM-less raw error, and the ref-source race were each reproduced before fixing.

@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from fbcdf51 to 53b4aa0 Compare July 2, 2026 11:17
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch 2 times, most recently from 8ee700d to 238166d Compare July 7, 2026 09:32
mfazekas added a commit that referenced this pull request Jul 7, 2026
- e2e: assert the stable not-found message on every backend and treat
  error.cause as optional (present only when the backend rejects). The
  old iOS branch expected a cause unconditionally and failed on the
  legacy backend, which resolves nil instead of throwing (reproduced on
  a USE_RIVE_LEGACY build).
- useRive: represent 'view not ready yet' as undefined and keep null
  for failure, matching the useRiveFile convention. Previously the
  initial null made useViewModelInstanceAsync(riveViewRef) settle to a
  terminal no-ViewModel state during normal mount, and required: true
  threw to the error boundary before the view attached.
- ios/new + android/new: resolve null from defaultArtboardViewModel*
  when the artboard has no default ViewModel instead of letting
  getDefaultViewModelInfo's rejection surface as a raw error for a
  valid VM-less file (issue #189 fixture; reproduced on-device, new
  e2e test guards it).
- comments: reword the categorical 'both platforms throw' claims — that
  only describes the new backend; legacy resolves nil/null and the
  not-found branches are its live path.

Verified: jest 74 passed; full iOS harness on the new backend 196
passed; e2e file on the legacy backend 10 passed.
mfazekas added a commit that referenced this pull request Jul 8, 2026
PR #304 supersedes the in-place async rewrite with a dedicated
useViewModelInstanceAsync hook (isLoading state, RiveViewRef bind polling,
error causes, companion native fixes) and deprecates this one instead.
useRiveProperty and useRiveList stay on the async API.
mfazekas added a commit that referenced this pull request Jul 8, 2026
- e2e: assert the stable not-found message on every backend and treat
  error.cause as optional (present only when the backend rejects). The
  old iOS branch expected a cause unconditionally and failed on the
  legacy backend, which resolves nil instead of throwing (reproduced on
  a USE_RIVE_LEGACY build).
- useRive: represent 'view not ready yet' as undefined and keep null
  for failure, matching the useRiveFile convention. Previously the
  initial null made useViewModelInstanceAsync(riveViewRef) settle to a
  terminal no-ViewModel state during normal mount, and required: true
  threw to the error boundary before the view attached.
- ios/new + android/new: resolve null from defaultArtboardViewModel*
  when the artboard has no default ViewModel instead of letting
  getDefaultViewModelInfo's rejection surface as a raw error for a
  valid VM-less file (issue #189 fixture; reproduced on-device, new
  e2e test guards it).
- comments: reword the categorical 'both platforms throw' claims — that
  only describes the new backend; legacy resolves nil/null and the
  not-found branches are its live path.

Verified: jest 74 passed; full iOS harness on the new backend 196
passed; e2e file on the legacy backend 10 passed.
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from 3540770 to c33740f Compare July 8, 2026 05:00
mfazekas added a commit that referenced this pull request Jul 8, 2026
- e2e: assert the stable not-found message on every backend and treat
  error.cause as optional (present only when the backend rejects). The
  old iOS branch expected a cause unconditionally and failed on the
  legacy backend, which resolves nil instead of throwing (reproduced on
  a USE_RIVE_LEGACY build).
- useRive: represent 'view not ready yet' as undefined and keep null
  for failure, matching the useRiveFile convention. Previously the
  initial null made useViewModelInstanceAsync(riveViewRef) settle to a
  terminal no-ViewModel state during normal mount, and required: true
  threw to the error boundary before the view attached.
- ios/new + android/new: resolve null from defaultArtboardViewModel*
  when the artboard has no default ViewModel instead of letting
  getDefaultViewModelInfo's rejection surface as a raw error for a
  valid VM-less file (issue #189 fixture; reproduced on-device, new
  e2e test guards it).
- comments: reword the categorical 'both platforms throw' claims — that
  only describes the new backend; legacy resolves nil/null and the
  not-found branches are its live path.

Verified: jest 74 passed; full iOS harness on the new backend 196
passed; e2e file on the legacy backend 10 passed.
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from c33740f to 0400053 Compare July 8, 2026 05:09
@mfazekas mfazekas changed the title feat(hooks): async useViewModelInstanceAsync (deprecate sync hook) feat(hooks): async instance creation via useViewModelInstance({ async: true }) Jul 8, 2026
mfazekas added a commit that referenced this pull request Jul 8, 2026
- iOS: index conversions use Int(exactly:) so NaN/inf/overflow inputs
  return nil instead of trapping (L2).
- iOS: blockingAsync throws a RuntimeError when the deprecated blocking
  API is invoked on the main thread (it would deadlock the MainActor work
  it waits on) instead of hard-trapping the app (L6); afterUpdate's
  file-nil transient logs a warning instead of a dev RedBox (L5).
- Podspec: drop the dead -DRIVE_EXPERIMENTAL_API from consumer-leaking
  s.xcconfig - nothing references the define (L4).
- Backend opt-out accepts both tokens on both platforms:
  USE_RIVE_LEGACY=1 and =true each work for the Podfile/env and the
  Gradle property (L17).
- CI: the cocoapods "save cache" steps referenced cache-key, which
  actions/cache/restore does not output (correct name:
  cache-primary-key) - the save silently no-oped on every run (L18).

Assessed and deliberately not taken: L7-L9 (image-set serialization,
multi-touch, pause-gated draws - feature work / needs upstream), L12
(superseded by #304's hook), L16 (shared image rawData memory - needs a
per-backend split), L1/L3 (log-handler locking, listener first-emission
semantics - low risk, need a design pass).
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from 2675467 to e5e3f24 Compare July 9, 2026 05:33
mfazekas added a commit that referenced this pull request Jul 9, 2026
- e2e: assert the stable not-found message on every backend and treat
  error.cause as optional (present only when the backend rejects). The
  old iOS branch expected a cause unconditionally and failed on the
  legacy backend, which resolves nil instead of throwing (reproduced on
  a USE_RIVE_LEGACY build).
- useRive: represent 'view not ready yet' as undefined and keep null
  for failure, matching the useRiveFile convention. Previously the
  initial null made useViewModelInstanceAsync(riveViewRef) settle to a
  terminal no-ViewModel state during normal mount, and required: true
  threw to the error boundary before the view attached.
- ios/new + android/new: resolve null from defaultArtboardViewModel*
  when the artboard has no default ViewModel instead of letting
  getDefaultViewModelInfo's rejection surface as a raw error for a
  valid VM-less file (issue #189 fixture; reproduced on-device, new
  e2e test guards it).
- comments: reword the categorical 'both platforms throw' claims — that
  only describes the new backend; legacy resolves nil/null and the
  not-found branches are its live path.

Verified: jest 74 passed; full iOS harness on the new backend 196
passed; e2e file on the legacy backend 10 passed.
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from e5e3f24 to 5c9890d Compare July 9, 2026 05:41
mfazekas added 12 commits July 9, 2026 23:09
The sync useViewModelInstance creates instances via deprecated runtime
APIs that touch the Rive runtime on the JS thread. Add
useViewModelInstanceAsync built on the non-deprecated `*Async` APIs,
returning a { instance, isLoading, error } discriminated union (mirroring
useRiveFile), with cancellation/disposal on deps change + unmount and
onInit applied before the instance is exposed.

Keeps `required` parity (throws once resolved to null; undefined while
loading). The sync hook is marked @deprecated but left working for
backward compatibility. Migrates the example screens to the async hook.
…ty, not just the view ref

On the experimental iOS backend the ViewModel instance and its properties
resolve asynchronously a short time after the view's hybridRef is assigned.
The test read `getViewModelInstance().numberProperty('ypos')` immediately
after waiting only for the ref, so on slower CI runners the property was
occasionally still undefined, failing at `expect(ypos1).toBeDefined()`
(~1 in 4 runs). Reproduced deterministically: at ref-callback time the VMI
is null 40/40, becoming ready shortly after.

Poll with waitFor until the `ypos` property is available before sampling it,
for both the initial mount and the post-switch reconfigure.
…odel property

Same race as 53b4aa0 (reconfigure test): the default ViewModel instance and its
properties resolve asynchronously a short time after the view's hybridRef is
assigned. The test read getViewModelInstance().numberProperty('ypos') right after
waiting only for the ref, so the VMI was intermittently still undefined —
`expect(vmi).not.toBeNull()` passed (undefined !== null) and the next line threw
"Cannot read property 'numberProperty' of undefined". Poll with waitFor until the
ypos property is available before sampling it.
The iOS harness job fails when the app crashes at launch (bridge never connects,
15-min timeout), but the existing debug step only dumps os_log — which comes back
empty for an early-launch crash. Add a step that collects RiveExample crash
reports (.ips/.crash) from the host and simulator DiagnosticReports dirs, prints
them to the log, and uploads them as an artifact, so the actual crash stack is
visible instead of an empty log.
Address the review findings on the async hook (same issues as #305, with
the design refined for this branch's native backends):

- Map a rejected artboard lookup to the friendly "Artboard 'X' not
  found or has no ViewModel" error — both platforms throw on an unknown
  artboard name rather than resolving undefined. A rejection with no
  artboardName still propagates unchanged.
- Keep the stable "ViewModel instance 'X' not found" message when
  createInstanceByNameAsync rejects, but attach the native error as
  cause instead of console.warn-ing it away.
- Settle to a terminal { instance: null, isLoading: false } when the
  source is null (useRiveFile's error state) instead of loading forever;
  undefined still means loading, mirroring useRiveFile's convention.
- QuickStart: surface useRiveFile/useViewModelInstanceAsync errors so a
  failing onInit no longer yields a silent blank screen.
- Add an on-device e2e harness covering the real native rejection paths
  the Jest mocks can't.
- e2e: assert the stable not-found message on every backend and treat
  error.cause as optional (present only when the backend rejects). The
  old iOS branch expected a cause unconditionally and failed on the
  legacy backend, which resolves nil instead of throwing (reproduced on
  a USE_RIVE_LEGACY build).
- useRive: represent 'view not ready yet' as undefined and keep null
  for failure, matching the useRiveFile convention. Previously the
  initial null made useViewModelInstanceAsync(riveViewRef) settle to a
  terminal no-ViewModel state during normal mount, and required: true
  threw to the error boundary before the view attached.
- ios/new + android/new: resolve null from defaultArtboardViewModel*
  when the artboard has no default ViewModel instead of letting
  getDefaultViewModelInfo's rejection surface as a raw error for a
  valid VM-less file (issue #189 fixture; reproduced on-device, new
  e2e test guards it).
- comments: reword the categorical 'both platforms throw' claims — that
  only describes the new backend; legacy resolves nil/null and the
  not-found branches are its live path.

Verified: jest 74 passed; full iOS harness on the new backend 196
passed; e2e file on the legacy backend 10 passed.
…sync

The auto-bound ViewModelInstance resolves a short time after the view
ref is assigned and there is no native bind-complete signal yet, so the
one-shot getViewModelInstance() read could settle a terminal
{ instance: null } on fast mounts (reproduced on-device with a raw
hybridRef that exposes the ref in the pre-bind window; the useRive
path happened to win the race). The ref path now polls every 50ms for
up to 5s, aborted on unmount/deps-change; a view with no data binding
still settles null, just late. New e2e + unit tests cover the ref
source path, which previously had none (including the instance-is-not-
disposed-on-unmount invariant).

Also from the review follow-ups:
- attach the native rejection as cause on the artboard not-found error
  (parity with instanceNotFoundError)
- preserve non-Error rejection values in the outer catch
- QuickStart: riveViewRef?.play() — buttons are tappable before the
  view mounts
- CI: collect iOS crash reports on test-harness-ios-legacy failures
  too (the legacy backend is where native crashes reproduced)
… creation

The wrapper is hook-internal and on the experimental backend owns the
artboard resolved for DefaultForArtboard sources; without an explicit
dispose it lingers until JS GC finalizes it.
…c: true })

Replace the separate useViewModelInstanceAsync export with an async
param on useViewModelInstance, so the API keeps a single name and the
migration is a param flip rather than a rename:

- overloads with async: true return the async result; the non-async
  overloads are @deprecated per-overload ('pass async: true') and will
  flip to async-by-default in the next major
- the sync result gains isLoading (true only while the source is
  pending), which makes the sync and async result unions structurally
  identical — call sites don't change shape when the default flips
- the async implementation is unchanged and stays in
  useViewModelInstanceAsync.ts as the internal hook behind the flag;
  the async param must stay constant for a component's lifetime (it
  selects between hook implementations)
- examples, unit tests, and the e2e harness now call
  useViewModelInstance(source, { async: true, ... }); repro/regression
  tests that intentionally ride the sync path carry explicit
  no-deprecated disables
The legacy backends implemented the *Async lookup APIs as
Promise.async { sync impl }, which moves Rive runtime access onto
Nitro's background pool (Swift concurrency executor / Dispatchers.
Default) — concurrent with the attached views rendering the same
RiveFile on the main thread, with no synchronization in the legacy
runtime. That is the race class TSan confirmed for issue #297, and
useViewModelInstance({ async: true }) routes all instance setup
through these paths.

iOS already main-hopped instance creation (Promise.onMain in
HybridViewModel); this applies the same policy to the file/ViewModel
lookups and to Android, where creation also ran on the pool. Android
uses a shared, never-cancelled main scope: a Promise launched on a
cancelled scope would never settle, so a lookup racing dispose() must
still resolve (the bodies null-guard the disposed file).

On the legacy backend "async" thus means "doesn't block the JS
thread", never "parallel with rendering". Not covered here (same
race class, pre-existing via useRiveList/useRiveProperty): the legacy
list-property operations and iOS legacy property setters still run on
the pool — follow-up.
…roid getViewModelInstance

Two fixes from the second cold review:

- The loading reset moved from the effect into render (adjust-state-
  during-render): the effect-time reset let React commit one frame
  pairing the new source with the previous instance at isLoading:
  false — a mismatch consumer guards can't catch (e.g. <RiveView
  file={fileB} dataBind={instanceA}/> reaching native) — and then
  disposed that instance while still committed. New unit test captures
  committed frames on a source flip and fails on the old behavior
  (26 mismatched frames).

- Legacy Android's HybridRiveView.getViewModelInstance() read
  controller.stateMachines on the JS thread while the main thread
  mutates it during startup (issue #297 race class; iOS already hops
  via MainThread.run). Now reads under
  runBlocking(Dispatchers.Main.immediate). No deterministic test is
  possible for the race; the ref-source e2e tests exercise this path
  on the android-legacy CI job.
…sync path

Two more items from the second cold review:

- Params objects built separately widen async to boolean (and the
  exported *Params types declare async?: boolean), matching no literal
  overload — the compiler rejected the call blaming the *source*
  argument, and values of the hook's own exported param types stopped
  being accepted. New catch-all overloads (per source type) restore
  compatibility; literal async values still resolve to the specific
  overloads first, keeping deprecation warnings and required narrowing.
  Type-level regression tests gate via yarn tsc.

- The sync (non-async) path labeled a null source isLoading: true
  forever — the exact spinner-hang the async docs warn about. It now
  settles to a terminal { instance: null, isLoading: false } and, with
  required: true, throws — mirroring the async path. Behavior change
  on the deprecated path, in the direction of the documented contract.
…logging

Remaining small items from the second cold review:

- required-mode throws no longer leak the internal hook name
  (useViewModelInstanceAsync: → useViewModelInstance:), and a null
  source gets its own message pointing at the upstream hook's error
  instead of 'ensure the source has a valid ViewModel'
- flipping the async param between renders now logs an actionable
  console.error before React's cryptic hooks-order invariant fires
- the VM-less normalization on the new backends logs what it swallows
  (RiveLog.d) and propagates cancellation instead of eating it
- e2e: expectDefined also rejects null (its NonNullable claim was
  unsound), the resolve-to-null test pins the stable 'not found'
  message, and android-experimental skips announce themselves
- JSDoc: the required example now states that required-narrowing needs
  a non-nullable source type (nullable sources resolve to the standard
  overload; the runtime throw still applies)
@mfazekas mfazekas force-pushed the feat/use-viewmodel-instance-async branch from 1aab8b3 to 8859f66 Compare July 9, 2026 21:11
mfazekas added 6 commits July 10, 2026 00:00
…VMI getter

- expo57-example/jest.config.js scoped its roots to ../example/src,
  which silently dropped ~20 on-device suites (all of example/__tests__,
  including this PR's e2e guards) from every harness job. Widen the
  root to ../example so the full shared suite runs against the Expo
  app, per the config's own stated intent.

- Replace the legacy Android getViewModelInstance main-hop
  (runBlocking(Dispatchers.Main.immediate), added earlier on this
  branch) with a main-thread-maintained @volatile snapshot: off-main
  callers get the last snapshot and schedule a refresh — eventually
  consistent, which suits the polling consumers (the async hook's ref
  path, harness waitFor loops). runBlocking JS→main can deadlock if the
  main thread ever waits on JS under the legacy bridge, and is the
  prime suspect in the android-legacy harness job timing out on all
  three attempts; the hazard is real regardless of that verdict.
The per-attempt watchdogs (iOS 300s x 5, Android timeout 300 x 3) were
sized for the 4-suite subset that ran while expo57's jest roots
excluded example/__tests__. The restored ~25-suite run gets through
about 15 suites before the 300s kill lands (exit 137, mid
reload-rebind on the observed runs — position, not culprit), so every
attempt dies the same way. iOS: 3 attempts x 600s (step timeout 15m ->
35m); Android: timeout 600, job timeout 30m -> 50m.
…anges

The snapshot getter's posted-refresh design broke the read-after-bind
contract: autoplay-false-trigger's issue #156 guard binds via the
dataBind prop and does a one-shot getViewModelInstance() read, which
returned the stale (null) snapshot on android-legacy (iOS still reads
synchronously). Binding always happens on the main thread, so refresh
the snapshot right where it changes (bindViewModelInstance and
bindToStateMachine) — a JS read after binding then sees a fresh value
with no blocking and no polling needed.
Replace the non-deprecated catch-all overloads with plain deprecated
overloads (no async?: false pin): a params object whose async widened
to boolean — or a value of the exported *Params types — now resolves
to the deprecated overload instead of a warning-free catch-all. Since
async is a member of the params types, such calls compile (no
weak-type failure) and behave per the runtime value; the deprecation
message tells the caller the fix: re-pin with { ...params, async:
true }. This makes deprecation coverage total — any call not provably
async: true is flagged — at the cost of a nudge for already-migrated
callers who kept a widened object, which the message resolves.

The type-compat tests keep pinning that these calls compile and run
async at runtime, now with intentional no-deprecated disables.
A dedicated typetest file asserts which calls resolve to @deprecated
overloads, in both directions: calls that must be deprecated carry a
no-deprecated disable, and eslint runs typetest files with
reportUnusedDisableDirectives: 'error' — so if the resolution ever
changes, the now-unused directive fails lint; calls that must stay
non-deprecated are written bare and fail no-deprecated directly on
regression. @ts-expect-error pins the invalid param combinations, and
return-type annotations pin the required-narrowing behavior. Gated by
yarn lint + yarn tsc (the jest body is a vessel; jest matches all of
__tests__).
Replace the session-invented eslint typetest convention with the tsd
infrastructure PR #294 introduces, copied as closely as possible so
whichever PR merges second reconciles trivially: tsd ^0.33.0, the
'typetest' script (tsd --typings src/index.tsx), the package.json tsd
block (directory src/__tests__, paths/moduleResolution/jsx options),
jest testPathIgnorePatterns for *.test-d.ts, eslint + root-tsconfig
excludes for *.test-d.ts.

The pins live in src/__tests__/useViewModelInstance.test-d.ts as
explicit assertions: expectDeprecated for every call that must resolve
to a deprecated overload (bare, async: false, widened boolean async,
exported param types), expectNotDeprecated for the literal async: true
forms including the { ...params, async: true } re-pin, expectType for
the required-narrowing behavior, and expectError for invalid param
combinations. Verified that tsd resolves deprecation per-overload for
call expressions (failure messages name the resolved signature).

One deliberate divergence from #294: CI gates 'yarn typetest' in the
lint job (theirs wires the script without a CI step) — keep the step
when the branches meet.
@mfazekas mfazekas marked this pull request as ready for review July 10, 2026 11:06
@mfazekas mfazekas merged commit efe96fa into feat/rive-ios-experimental Jul 10, 2026
25 of 26 checks passed
@mfazekas mfazekas deleted the feat/use-viewmodel-instance-async branch July 10, 2026 11:07
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.

1 participant