Skip to content

React 19 bugs#87

Open
js2me wants to merge 11 commits into
masterfrom
react-19-bugs
Open

React 19 bugs#87
js2me wants to merge 11 commits into
masterfrom
react-19-bugs

Conversation

@js2me

@js2me js2me commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary by cubic

Fixes duplicate ViewModel instances and infinite remount loops with React 19 Suspense/lazy by changing when VMs attach and by evicting duplicates in the store. Adds React 18/19 regression tests and a test:react-regression script to prevent future regressions.

  • Bug Fixes

    • Attach VMs during render in useCreateViewModel, defer detach to a requestAnimationFrame cleanup, and guard against Offscreen re-attach; prevents split MobX reactions and remount loops in React 19.
    • In ViewModelStoreBase, evict same-constructor siblings with the same parent (evictOrphans), consider the temp heap in isAbleToRenderView, skip mount if already mounted, and tighten attach/detach cleanup.
  • Refactors

    • Replaced type-only consumers with runnable React 18/19 regression apps under tests/react-regressions/* (Suspense/lazy + observer/withViewModel cases), and updated workspace and .gitignore.
    • Added scripts/test-react-type-regression.mjs and pnpm run test:react-regression to install ephemeral fixtures and run the new suites.

Written for commit f373ab5. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added React 18 and React 19 regression test suites to better validate Suspense and lazy-loading behavior.
    • Expanded automated testing to cover both type and runtime React regression scenarios.
  • Bug Fixes

    • Improved handling to prevent duplicate view-model instances during deferred rendering.
    • Reduced the chance of cleanup-related issues when components unmount or reattach quickly.
  • Chores

    • Updated workspace and ignore settings to include the new React regression test fixtures.

@js2me js2me changed the title React 19 improvenments React 19 bugs Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes duplicate view-model creation under React Suspense/lazy loading by adding orphan-eviction logic to ViewModelStoreBase and RAF-deferred cleanup guards in useCreateViewModel. It also replaces the old type-regressions consumer fixtures with new react-regressions React 18/19 test packages containing Suspense duplicate-prevention tests.

Changes

Core Store and Hook Fixes

Layer / File(s) Summary
Orphan eviction and temp-heap aware rendering
packages/core/src/view-model/view-model.store.base.ts
Adds getParentId/evictOrphans helpers invoked during attach to detach/remove stale same-constructor view models, refactors temp-heap cleanup into a shared callback used on sync/promise/error mount paths, and updates isAbleToRenderView to treat temp-heap entries as renderable.
Deferred RAF cleanup in useCreateViewModel
packages/react/src/hooks/use-create-view-model.ts
Refactors instance creation via a reusable configCreate object, updates the React 19 Offscreen comment, and replaces immediate layout-effect cleanup with requestAnimationFrame-deferred detach/unmount guarded by lastAttachedInstanceRef, in both base and simple hook variants.

React Regression Test Suite Migration

Layer / File(s) Summary
Runner script and workspace wiring
package.json, pnpm-workspace.yaml, .gitignore, scripts/test-react-type-regression.mjs
Renames the test script to test:react-regression, adds tests/react-regressions/* to workspace globs and .gitignore, and updates the runner to target new fixture paths with conditional pnpm-workspace.yaml handling and an allowBuilds entry.
React 18 regression package
tests/react-regressions/react18/package.json, tests/react-regressions/react18/vitest.config.ts, tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx
Adds a React 18 test package with Vite/Vitest config and Suspense duplicate-prevention tests covering useCreateViewModel and withViewModel.
React 19 regression package
tests/react-regressions/react19/package.json, tests/react-regressions/react19/vitest.config.ts, tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx
Adds a mirrored React 19 test package with Vitest config and Suspense duplicate-prevention tests.
Removed type-regression consumer fixtures
tests/type-regressions/consumer-react18/*, tests/type-regressions/consumer-react19/*
Removes the old package.json and pnpm-workspace.yaml for the react18/react19 consumer fixtures, superseded by the new react-regressions packages.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReactComponent
  participant useCreateViewModel
  participant ViewModelStoreBase
  participant TempHeap

  ReactComponent->>useCreateViewModel: render (lazy/Suspense)
  useCreateViewModel->>ViewModelStoreBase: attach(model)
  ViewModelStoreBase->>ViewModelStoreBase: getParentId(model)
  ViewModelStoreBase->>ViewModelStoreBase: evictOrphans(constructor, parentId)
  ViewModelStoreBase->>TempHeap: detach/remove stale orphan VMs
  ViewModelStoreBase-->>useCreateViewModel: attach complete
  ReactComponent->>useCreateViewModel: unmount (effect cleanup)
  useCreateViewModel->>useCreateViewModel: requestAnimationFrame(cleanup)
  useCreateViewModel->>useCreateViewModel: check lastAttachedInstanceRef
  alt still last attached
    useCreateViewModel->>ViewModelStoreBase: detach(detachId)
  else superseded instance
    useCreateViewModel->>useCreateViewModel: skip cleanup
  end
Loading

Possibly related PRs

  • js2me/mobx-view-model#59: Modifies the same ViewModelStoreBase attach/mount behavior and useCreateViewModel cleanup/lastAttachedInstanceRef logic that this PR further refines.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is related to the main change: React 19 regression fixes and bug reproduction coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch react-19-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/react-regressions/react18/package.json (1)

2-2: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Package name still uses "type-regression" naming.

The package is named consumer-type-regression-react18 but lives under tests/react-regressions/. Consider renaming to consumer-react-regression-react18 for consistency with the new directory structure and the test:react-regression script name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/react-regressions/react18/package.json` at line 2, The package name
still uses the old type-regression label; update the name in the react18 package
manifest from the current consumer-type-regression style to the
consumer-react-regression convention so it matches tests/react-regressions and
the test:react-regression script. Keep the change limited to the package naming
field in the package.json for this React 18 regression test package.
tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx (1)

1-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting shared test setup to reduce duplication.

The React 18 and React 19 test files are nearly identical (same mock classes, same test structure, same assertions). Any future test changes must be applied to both files. Consider extracting the shared ViewModelBaseMock, ViewModelStoreBaseMock, RouteStore, and the two test case bodies into a shared module that both fixture packages import, parameterized by React version if needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx` around
lines 1 - 217, The React 19 suspense regression test duplicates the same mock
setup and test bodies found in the React 18 fixture, so future changes must be
made in two places. Extract the साझा test scaffolding and assertions around
ViewModelBaseMock, ViewModelStoreBaseMock, RouteStore, and the two
suspense/withViewModel scenarios into a shared helper module, then have both
React version test files import it and supply any version-specific setup if
needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/view-model/view-model.store.base.ts`:
- Around line 362-393: evictOrphans currently treats any same-class, same-parent
instance as an orphan and deletes live siblings, which conflicts with the
multiple-instance behavior used by markToBeAttached, attach, getIds, and getAll.
Update evictOrphans in view-model.store.base.ts to only evict stale duplicates
for the exact model being attached, not all matching constructor/parentId
siblings; preserve existing attached ViewModelSimple and root ViewModel
instances when they are still live, and only remove the prior temp-heap
placeholder or the previous instance that is being replaced.

In `@packages/react/src/hooks/use-create-view-model.ts`:
- Around line 184-200: The RAF-deferred cleanup in useIsomorphicLayoutEffect can
leave a VM attached or accidentally tear down a live one when the callback is
delayed. Update the cleanup around instance, lastAttachedInstanceRef, and the
requestAnimationFrame detach logic to use a guaranteed/unified cleanup path or
cancelable fallback so detach()/unmount() always runs reliably and cannot fire
against a remounted instance.

In `@tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx`:
- Around line 183-197: The second Suspense regression test is missing the
intermediate PageVM assertion that mirrors the React 19 case. In the test around
the navigate/resolve flow using routeStore.navigate, resolveModule,
vmStore.getIds(LayoutVM), and vmStore.getIds(PageVM), add an assertion after
navigation but before module resolution to verify PageVM is still absent during
the loading state, matching the first test’s expectations.
- Around line 117-131: The Suspense regression test is missing the intermediate
PageVM assertion while the lazy page is still unresolved. In
suspense-vm-duplicate.test.tsx, update the test around routeStore.navigate and
resolveModule to assert vmStore.getIds(PageVM) has length 0 immediately after
the loading fallback is shown, before the module resolves. Keep the existing
LayoutVM and final PageVM assertions so the test still verifies no duplicate VM
is created during the fallback state.

---

Nitpick comments:
In `@tests/react-regressions/react18/package.json`:
- Line 2: The package name still uses the old type-regression label; update the
name in the react18 package manifest from the current consumer-type-regression
style to the consumer-react-regression convention so it matches
tests/react-regressions and the test:react-regression script. Keep the change
limited to the package naming field in the package.json for this React 18
regression test package.

In `@tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx`:
- Around line 1-217: The React 19 suspense regression test duplicates the same
mock setup and test bodies found in the React 18 fixture, so future changes must
be made in two places. Extract the साझा test scaffolding and assertions around
ViewModelBaseMock, ViewModelStoreBaseMock, RouteStore, and the two
suspense/withViewModel scenarios into a shared helper module, then have both
React version test files import it and supply any version-specific setup if
needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60ec0753-7a22-4751-ba8c-6ae97bed219e

📥 Commits

Reviewing files that changed from the base of the PR and between b63d3c3 and f373ab5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • .gitignore
  • package.json
  • packages/core/src/view-model/view-model.store.base.ts
  • packages/react/src/hooks/use-create-view-model.ts
  • pnpm-workspace.yaml
  • scripts/test-react-type-regression.mjs
  • tests/react-regressions/react18/package.json
  • tests/react-regressions/react18/src/repro.tsx
  • tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx
  • tests/react-regressions/react18/tsconfig.json
  • tests/react-regressions/react18/vitest.config.ts
  • tests/react-regressions/react19/package.json
  • tests/react-regressions/react19/src/repro.tsx
  • tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx
  • tests/react-regressions/react19/tsconfig.json
  • tests/react-regressions/react19/vitest.config.ts
  • tests/type-regressions/consumer-react18/package.json
  • tests/type-regressions/consumer-react18/pnpm-workspace.yaml
  • tests/type-regressions/consumer-react19/package.json
  • tests/type-regressions/consumer-react19/pnpm-workspace.yaml
💤 Files with no reviewable changes (4)
  • tests/type-regressions/consumer-react18/package.json
  • tests/type-regressions/consumer-react19/pnpm-workspace.yaml
  • tests/type-regressions/consumer-react19/package.json
  • tests/type-regressions/consumer-react18/pnpm-workspace.yaml

Comment on lines +362 to +393
protected getParentId(vm: VMBase | AnyViewModelSimple) {
return isViewModel(vm) ? vm.parentViewModel?.id : null
}

protected evictOrphans(
model: VMBase | AnyViewModelSimple,
modelId: string,
): void {
const isInTempHeap = this.viewModelsTempHeap.has(modelId);
const constructor = (model as any).constructor as Class<any, any>;
const vmIds = this.viewModelIdsByClasses.get(constructor);
if (!vmIds) return;

const parentId = this.getParentId(model);

for (const existingId of [...vmIds]) {
if (existingId === modelId) continue;
const existingVm =
this.viewModels.get(existingId) ??
this.viewModelsTempHeap.get(existingId);
if (!existingVm) continue;
if (this.getParentId(existingVm) !== parentId) continue;

if (isInTempHeap && this.viewModels.has(existingId)) {
void this.detach(existingId);
} else if (this.viewModelsTempHeap.has(existingId)) {
this.viewModelsTempHeap.delete(existingId);
this.dettachVMConstructor(existingVm);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect ViewModelSimple for payloadChanged, and look for multi-instance/simple-VM eviction tests.
fd -t f 'typeguards' packages/core/src | xargs -I{} sed -n '1,40p' {}
rg -nP '\bpayloadChanged\b' packages/core/src -C2
rg -nP 'evictOrphans|getParentId' packages/core -C3
# Any tests attaching >1 instance of same VM class under same parent, or simple VMs?
rg -nP 'attach\(|markToBeAttached|ViewModelSimple' packages/core/src/view-model/view-model.store.base.test.ts -C2

Repository: js2me/mobx-view-model

Length of output: 11433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the store and test files first, then inspect the eviction/attach logic and any tests
# that exercise multiple instances of the same class or simple view models.
ast-grep outline packages/core/src/view-model/view-model.store.base.ts --view expanded
ast-grep outline packages/core/src/view-model/view-model.store.base.test.ts --view expanded

# Read the relevant implementation sections with line numbers.
sed -n '330,460p' packages/core/src/view-model/view-model.store.base.ts | cat -n
sed -n '1,260p' packages/core/src/view-model/view-model.store.base.test.ts | cat -n

# Search for any tests or code paths that imply same-class siblings are allowed under one parent.
rg -n "getIds|getId\(|viewModelIdsByClasses|instanceAttachedCount|evictOrphans|detach\(" packages/core/src -C 2
rg -n "ViewModelSimple|isViewModeSimpleClass|payloadChanged|parentViewModel" packages/core/src -C 2

Repository: js2me/mobx-view-model

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the simple VM tests and the parent-related APIs.
sed -n '1,220p' packages/core/src/view-model/view-model-simple.test.ts | cat -n
sed -n '1,220p' packages/core/src/view-model/view-model-simple.ts | cat -n
sed -n '1,120p' packages/core/src/view-model/view-model.ts | cat -n

# Narrow search for explicit multi-instance or one-per-parent assumptions.
rg -n "one per|multiple instances|same parent|parentViewModelId|getIds\(" packages/core/src/view-model -C 2

Repository: js2me/mobx-view-model

Length of output: 11895


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where markToBeAttached is called and whether temp-heap attachment is the normal path.
rg -n "markToBeAttached\(" packages/core/src -C 3
sed -n '1,220p' packages/core/src/view-model/view-model.base.ts | cat -n
sed -n '220,360p' packages/core/src/view-model/view-model.store.base.ts | cat -n

Repository: js2me/mobx-view-model

Length of output: 14925


evictOrphans can remove live siblings of the same class. In packages/core/src/view-model/view-model.store.base.ts:366-386, markToBeAttached() + attach() only keys on (constructor, parentId), so a new ViewModelSimple or root ViewModel with the same parent key can evict an existing same-class instance. The plural lookup APIs (getIds()/getAll()) and the simple-VM tests both allow multiple same-class instances to coexist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/view-model/view-model.store.base.ts` around lines 362 -
393, evictOrphans currently treats any same-class, same-parent instance as an
orphan and deletes live siblings, which conflicts with the multiple-instance
behavior used by markToBeAttached, attach, getIds, and getAll. Update
evictOrphans in view-model.store.base.ts to only evict stale duplicates for the
exact model being attached, not all matching constructor/parentId siblings;
preserve existing attached ViewModelSimple and root ViewModel instances when
they are still live, and only remove the prior temp-heap placeholder or the
previous instance that is being replaced.

Comment on lines +184 to +200
useIsomorphicLayoutEffect(() => {
return () => {
const detachInstance = instance;
const detachId = instance.id;
requestAnimationFrame(() => {
if (lastAttachedInstanceRef.current === detachInstance) {
if (viewModels) {
void viewModels.detach(detachId);
} else {
detachInstance.unmount();
}
lastAttachedInstanceRef.current = null;
}
});
};
}, [instance, viewModels]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does requestAnimationFrame callback run when the browser tab is hidden or backgrounded in React 19?

💡 Result:

No, the requestAnimationFrame callback does not run (or is heavily throttled/paused) when a browser tab is hidden or backgrounded [1][2]. This behavior is not specific to React 19; it is a standard performance and battery-saving optimization implemented by all major modern browsers [1][2]. When a tab is hidden, the browser pauses requestAnimationFrame callbacks because they are intended to coordinate animations with the display refresh rate, which is unnecessary when the content is not visible [1][3]. In the context of React, this can lead to specific issues where tasks gated by requestAnimationFrame are delayed until the tab becomes visible again [4][5]. For example: 1. Suspense Boundaries: In some cases, React's internal mechanisms (such as those used for streaming Suspense) have historically relied on requestAnimationFrame to flush updates [4]. If the tab is hidden while these updates are pending, the hydration or reveal might be delayed indefinitely until the user returns to the tab [4][6]. 2. Batching and State Updates: Since React often uses requestAnimationFrame internally to batch state updates or schedule rendering, these processes will be deferred while the tab is backgrounded [7][5]. When the tab returns to the foreground, React may then process a backlog of updates, which can occasionally lead to performance hitches if many updates were queued while the tab was inactive [5]. Modern versions of React, including React 19, are designed to be aware of these browser limitations and often include fallback mechanisms (such as using setTimeout) to ensure that critical work eventually proceeds even when requestAnimationFrame is not firing [7][6]. If you are encountering issues with delayed execution in background tabs, it is generally recommended to avoid relying on requestAnimationFrame for non-animation logic or to implement visibility-aware checks (using the Page Visibility API) to handle essential tasks differently when the tab is hidden [4][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the cited lines
FILE="packages/react/src/hooks/use-create-view-model.ts"
wc -l "$FILE"
sed -n '1,280p' "$FILE"

# Find related uses of requestAnimationFrame and instance lifecycle handling
rg -n "requestAnimationFrame|cancelAnimationFrame|lastAttachedInstanceRef|detach\(|unmount\(" packages/react/src -S

# Map nearby hook/function structure in the target file
ast-grep outline "$FILE" --view expanded

Repository: js2me/mobx-view-model

Length of output: 11157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for Offscreen/Activity/re-attach handling and tests around this hook
rg -n "Offscreen|Activity|reappear|re-attach|reattach|lastAttachedInstanceRef|useValue\\(|requestAnimationFrame" packages/react/src -S

# Inspect the hook tests and the shared hook helper implementation
FILE1="packages/react/src/hooks/use-create-view-model.test.tsx"
FILE2="packages/react/src/lib/hooks/index.ts"
FILE3="packages/react/src/lib/hooks/use-value.ts"

for f in "$FILE1" "$FILE2" "$FILE3"; do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
  fi
done

# Read the relevant test file sections if present
if [ -f "$FILE1" ]; then
  sed -n '1,260p' "$FILE1"
  sed -n '260,560p' "$FILE1"
fi

# Read helper exports and implementation if present
if [ -f "$FILE2" ]; then
  sed -n '1,220p' "$FILE2"
fi
if [ -f "$FILE3" ]; then
  sed -n '1,260p' "$FILE3"
fi

Repository: js2me/mobx-view-model

Length of output: 18386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate any requestAnimationFrame mocks or test setup that affects timing
rg -n "requestAnimationFrame|cancelAnimationFrame|vi\\.useFakeTimers|advanceTimers|runAllTimers|jsdom" \
  packages/react test tests -S

# Inspect the hook test around the unmount cases and any timing-related helpers
FILE="packages/react/src/hooks/use-create-view-model.test.tsx"
sed -n '300,520p' "$FILE"
sed -n '520,760p' "$FILE"

# Inspect any shared test setup files that might patch RAF
fd -a "setup*.{ts,tsx,js,jsx}" .
fd -a "*test*setup*"

Repository: js2me/mobx-view-model

Length of output: 1164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the hook helper and any timing-specific test setup without broad output
sed -n '1,220p' packages/react/src/lib/hooks/use-isomorphic-layout-effect.ts
sed -n '300,520p' packages/react/src/hooks/use-create-view-model.test.tsx

# Search for any direct RAF shims or cleanup cancellation patterns in the react package
rg -n "cancelAnimationFrame|requestAnimationFrame|raf|useIsomorphicLayoutEffect" packages/react/src packages/react/test -S

# Read the react vitest config and setup files if present
sed -n '1,220p' packages/react/vitest.config.ts
fd -a "setup*.ts" packages/react

Repository: js2me/mobx-view-model

Length of output: 6398


RAF-deferred cleanup can leak or tear down a live VM. requestAnimationFrame can be paused in hidden tabs, so detach()/unmount() may never run after unmount. The lastAttachedInstanceRef guard also doesn’t protect a preserved Offscreen reappear: if the instance is shown again before the callback fires, the queued detach can still hit the live VM. Use a guaranteed cleanup path or a cancelable fallback instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/hooks/use-create-view-model.ts` around lines 184 - 200,
The RAF-deferred cleanup in useIsomorphicLayoutEffect can leave a VM attached or
accidentally tear down a live one when the callback is delayed. Update the
cleanup around instance, lastAttachedInstanceRef, and the requestAnimationFrame
detach logic to use a guaranteed/unified cleanup path or cancelable fallback so
detach()/unmount() always runs reliably and cannot fire against a remounted
instance.

Comment on lines +117 to +131
await act(async () => {
routeStore.navigate('page');
});

expect(screen.getByTestId('loading')).toBeDefined();

await act(async () => {
resolveModule({ default: PageComponent });
});

expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);

expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add missing PageVM count assertion during Suspense state.

The React 19 version of this test (line 116) includes expect(vmStore.getIds(PageVM)).toHaveLength(0); after navigating to the page but before resolving the lazy module. This assertion verifies that no duplicate VM is created during the Suspense fallback state — a core part of the regression being tested. The React 18 test omits this check, weakening its coverage.

🛡️ Proposed fix
     expect(screen.getByTestId('loading')).toBeDefined();
+    expect(vmStore.getIds(PageVM)).toHaveLength(0);
 
     await act(async () => {
       resolveModule({ default: PageComponent });
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await act(async () => {
routeStore.navigate('page');
});
expect(screen.getByTestId('loading')).toBeDefined();
await act(async () => {
resolveModule({ default: PageComponent });
});
expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);
expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();
await act(async () => {
routeStore.navigate('page');
});
expect(screen.getByTestId('loading')).toBeDefined();
expect(vmStore.getIds(PageVM)).toHaveLength(0);
await act(async () => {
resolveModule({ default: PageComponent });
});
expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);
expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx` around
lines 117 - 131, The Suspense regression test is missing the intermediate PageVM
assertion while the lazy page is still unresolved. In
suspense-vm-duplicate.test.tsx, update the test around routeStore.navigate and
resolveModule to assert vmStore.getIds(PageVM) has length 0 immediately after
the loading fallback is shown, before the module resolves. Keep the existing
LayoutVM and final PageVM assertions so the test still verifies no duplicate VM
is created during the fallback state.

Comment on lines +183 to +197
await act(async () => {
routeStore.navigate('page');
});

expect(screen.getByTestId('loading')).toBeDefined();

await act(async () => {
resolveModule({ default: PageComponent });
});

expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);

expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add missing PageVM count assertion in second test during Suspense state.

Same as the first test case — the React 19 version (line 205) asserts expect(vmStore.getIds(PageVM)).toHaveLength(0); after navigating but before resolving the module. This check is absent here.

🛡️ Proposed fix
     expect(screen.getByTestId('loading')).toBeDefined();
+    expect(vmStore.getIds(PageVM)).toHaveLength(0);
 
     await act(async () => {
       resolveModule({ default: PageComponent });
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await act(async () => {
routeStore.navigate('page');
});
expect(screen.getByTestId('loading')).toBeDefined();
await act(async () => {
resolveModule({ default: PageComponent });
});
expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);
expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();
await act(async () => {
routeStore.navigate('page');
});
expect(screen.getByTestId('loading')).toBeDefined();
expect(vmStore.getIds(PageVM)).toHaveLength(0);
await act(async () => {
resolveModule({ default: PageComponent });
});
expect(vmStore.getIds(LayoutVM)).toHaveLength(1);
expect(vmStore.getIds(PageVM)).toHaveLength(1);
expect(screen.getByTestId('page')).toBeDefined();
expect(screen.queryByTestId('loading')).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx` around
lines 183 - 197, The second Suspense regression test is missing the intermediate
PageVM assertion that mirrors the React 19 case. In the test around the
navigate/resolve flow using routeStore.navigate, resolveModule,
vmStore.getIds(LayoutVM), and vmStore.getIds(PageVM), add an assertion after
navigation but before module resolution to verify PageVM is still absent during
the loading state, matching the first test’s expectations.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 21 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="package.json">

<violation number="1" location="package.json:17">
P0: CI workflow `.github/workflows/main.yml` still runs `pnpm test:type-regression` but this script was renamed to `test:react-regression` in `package.json`. CI will fail because `test:type-regression` no longer exists. Update the workflow to use the new script name.</violation>
</file>

<file name="packages/core/src/view-model/view-model.store.base.ts">

<violation number="1" location="packages/core/src/view-model/view-model.store.base.ts:363">
P2: Simple view models under different parents can evict each other because `getParentId` ignores `ViewModelSimple.parentViewModel` and returns `null` for every non-`ViewModel`. Using the shared `parentViewModel?.id` property here would keep orphan eviction scoped to the actual parent.</violation>

<violation number="2" location="packages/core/src/view-model/view-model.store.base.ts:383">
P2: `evictOrphans` matches by `(constructor, parentId)`, so attaching a second instance of the same VM class under the same parent (or two root VMs of the same class, both with `parentId = null`) will evict the first live instance. Since the store's plural lookup APIs (`getIds()`, `getAll()`) and the simple-VM pattern both support multiple same-class instances coexisting, this eviction logic is too broad and could remove legitimate siblings.</violation>

<violation number="3" location="packages/core/src/view-model/view-model.store.base.ts:386">
P2: `evictOrphans` calls `void this.detach(existingId)` which is a fire-and-forget async call. The `detach()` method is `async` and calls `await this.unmount(model)` internally. If `unmount()` throws (e.g., user code in `willUnmount` or `didUnmount` throws), the resulting promise rejection is unhandled — it is neither caught nor awaited, causing an unhandled promise rejection that in Node.js 15+ can terminate the process. Consider catching the error or using a `.catch()` handler.</violation>
</file>

<file name="tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx">

<violation number="1" location="tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx:71">
P3: Redundant assertion: `screen.getByTestId('page')` already validates element existence by throwing on not-found, so `.toBeDefined()` on its return value always passes and provides no additional safety. Consider `screen.findByTestId('page')` for async assertions, or simply drop `.toBeDefined()` and rely on `getByTestId`'s throw.</violation>
</file>

<file name="packages/react/src/hooks/use-create-view-model.ts">

<violation number="1" location="packages/react/src/hooks/use-create-view-model.ts:188">
P1: A VM can be detached/unmounted after it has already reappeared or been remounted before the next frame, because the deferred cleanup is never canceled and its per-hook ref still points at the old instance. Consider tracking/canceling the pending detach on effect setup or using a mount generation so stale RAF callbacks cannot tear down an active ViewModel.</violation>
</file>

<file name="tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx">

<violation number="1" location="tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx:121">
P3: The React 19 variant of this test asserts `expect(vmStore.getIds(PageVM)).toHaveLength(0)` during the Suspense fallback state, verifying no premature VM is created. This assertion is missing here in the React 18 test, reducing its ability to catch the specific regression being tested.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread package.json
"test:watch": "pnpm -r --filter './packages/*' run test:watch",
"test:coverage": "pnpm -r --filter './packages/*' run test:coverage",
"test:type-regression": "node ./scripts/test-type-regression.mjs",
"test:react-regression": "node ./scripts/test-react-type-regression.mjs && pnpm -r --filter './tests/react-regressions/*' run test",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: CI workflow .github/workflows/main.yml still runs pnpm test:type-regression but this script was renamed to test:react-regression in package.json. CI will fail because test:type-regression no longer exists. Update the workflow to use the new script name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At package.json, line 17:

<comment>CI workflow `.github/workflows/main.yml` still runs `pnpm test:type-regression` but this script was renamed to `test:react-regression` in `package.json`. CI will fail because `test:type-regression` no longer exists. Update the workflow to use the new script name.</comment>

<file context>
@@ -14,7 +14,7 @@
     "test:watch": "pnpm -r --filter './packages/*' run test:watch",
     "test:coverage": "pnpm -r --filter './packages/*' run test:coverage",
-    "test:type-regression": "node ./scripts/test-type-regression.mjs",
+    "test:react-regression": "node ./scripts/test-react-type-regression.mjs && pnpm -r --filter './tests/react-regressions/*' run test",
     "docs": "pnpm build && pnpm --filter mobx-view-model-docs run dev",
     "docs:build": "pnpm build && pnpm --filter mobx-view-model-docs run build",
</file context>

return () => {
const detachInstance = instance;
const detachId = instance.id;
requestAnimationFrame(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A VM can be detached/unmounted after it has already reappeared or been remounted before the next frame, because the deferred cleanup is never canceled and its per-hook ref still points at the old instance. Consider tracking/canceling the pending detach on effect setup or using a mount generation so stale RAF callbacks cannot tear down an active ViewModel.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react/src/hooks/use-create-view-model.ts, line 188:

<comment>A VM can be detached/unmounted after it has already reappeared or been remounted before the next frame, because the deferred cleanup is never canceled and its per-hook ref still points at the old instance. Consider tracking/canceling the pending detach on effect setup or using a mount generation so stale RAF callbacks cannot tear down an active ViewModel.</comment>

<file context>
@@ -197,6 +181,23 @@ const useCreateViewModelBase = (
+    return () => {
+      const detachInstance = instance;
+        const detachId = instance.id;
+        requestAnimationFrame(() => {
+        if (lastAttachedInstanceRef.current === detachInstance) {
+          if (viewModels) {
</file context>

}

protected getParentId(vm: VMBase | AnyViewModelSimple) {
return isViewModel(vm) ? vm.parentViewModel?.id : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Simple view models under different parents can evict each other because getParentId ignores ViewModelSimple.parentViewModel and returns null for every non-ViewModel. Using the shared parentViewModel?.id property here would keep orphan eviction scoped to the actual parent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/view-model/view-model.store.base.ts, line 363:

<comment>Simple view models under different parents can evict each other because `getParentId` ignores `ViewModelSimple.parentViewModel` and returns `null` for every non-`ViewModel`. Using the shared `parentViewModel?.id` property here would keep orphan eviction scoped to the actual parent.</comment>

<file context>
@@ -358,6 +359,38 @@ export class ViewModelStoreBase<VMBase extends AnyViewModel = AnyViewModel>
   }
 
+  protected getParentId(vm: VMBase | AnyViewModelSimple) {
+    return isViewModel(vm) ? vm.parentViewModel?.id : null
+  }
+
</file context>
Suggested change
return isViewModel(vm) ? vm.parentViewModel?.id : null
return vm.parentViewModel?.id ?? null;

if (this.getParentId(existingVm) !== parentId) continue;

if (isInTempHeap && this.viewModels.has(existingId)) {
void this.detach(existingId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: evictOrphans calls void this.detach(existingId) which is a fire-and-forget async call. The detach() method is async and calls await this.unmount(model) internally. If unmount() throws (e.g., user code in willUnmount or didUnmount throws), the resulting promise rejection is unhandled — it is neither caught nor awaited, causing an unhandled promise rejection that in Node.js 15+ can terminate the process. Consider catching the error or using a .catch() handler.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/view-model/view-model.store.base.ts, line 386:

<comment>`evictOrphans` calls `void this.detach(existingId)` which is a fire-and-forget async call. The `detach()` method is `async` and calls `await this.unmount(model)` internally. If `unmount()` throws (e.g., user code in `willUnmount` or `didUnmount` throws), the resulting promise rejection is unhandled — it is neither caught nor awaited, causing an unhandled promise rejection that in Node.js 15+ can terminate the process. Consider catching the error or using a `.catch()` handler.</comment>

<file context>
@@ -358,6 +359,38 @@ export class ViewModelStoreBase<VMBase extends AnyViewModel = AnyViewModel>
+      if (this.getParentId(existingVm) !== parentId) continue;
+
+      if (isInTempHeap && this.viewModels.has(existingId)) {
+        void this.detach(existingId);
+      } else if (this.viewModelsTempHeap.has(existingId)) {
+        this.viewModelsTempHeap.delete(existingId);
</file context>
Suggested change
void this.detach(existingId);
if (isInTempHeap && this.viewModels.has(existingId)) {
this.detach(existingId).catch(() => {});
} else if (this.viewModelsTempHeap.has(existingId)) {

this.viewModels.get(existingId) ??
this.viewModelsTempHeap.get(existingId);
if (!existingVm) continue;
if (this.getParentId(existingVm) !== parentId) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: evictOrphans matches by (constructor, parentId), so attaching a second instance of the same VM class under the same parent (or two root VMs of the same class, both with parentId = null) will evict the first live instance. Since the store's plural lookup APIs (getIds(), getAll()) and the simple-VM pattern both support multiple same-class instances coexisting, this eviction logic is too broad and could remove legitimate siblings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/view-model/view-model.store.base.ts, line 383:

<comment>`evictOrphans` matches by `(constructor, parentId)`, so attaching a second instance of the same VM class under the same parent (or two root VMs of the same class, both with `parentId = null`) will evict the first live instance. Since the store's plural lookup APIs (`getIds()`, `getAll()`) and the simple-VM pattern both support multiple same-class instances coexisting, this eviction logic is too broad and could remove legitimate siblings.</comment>

<file context>
@@ -358,6 +359,38 @@ export class ViewModelStoreBase<VMBase extends AnyViewModel = AnyViewModel>
+        this.viewModels.get(existingId) ??
+        this.viewModelsTempHeap.get(existingId);
+      if (!existingVm) continue;
+      if (this.getParentId(existingVm) !== parentId) continue;
+
+      if (isInTempHeap && this.viewModels.has(existingId)) {
</file context>


const PageComponent = observer(() => {
const vm = useCreateViewModel(PageVM);
return <span data-testid="page">{vm.id}</span>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Redundant assertion: screen.getByTestId('page') already validates element existence by throwing on not-found, so .toBeDefined() on its return value always passes and provides no additional safety. Consider screen.findByTestId('page') for async assertions, or simply drop .toBeDefined() and rely on getByTestId's throw.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/react-regressions/react19/src/suspense-vm-duplicate.test.tsx, line 71:

<comment>Redundant assertion: `screen.getByTestId('page')` already validates element existence by throwing on not-found, so `.toBeDefined()` on its return value always passes and provides no additional safety. Consider `screen.findByTestId('page')` for async assertions, or simply drop `.toBeDefined()` and rely on `getByTestId`'s throw.</comment>

<file context>
@@ -0,0 +1,217 @@
+
+    const PageComponent = observer(() => {
+      const vm = useCreateViewModel(PageVM);
+      return <span data-testid="page">{vm.id}</span>;
+    });
+
</file context>

routeStore.navigate('page');
});

expect(screen.getByTestId('loading')).toBeDefined();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The React 19 variant of this test asserts expect(vmStore.getIds(PageVM)).toHaveLength(0) during the Suspense fallback state, verifying no premature VM is created. This assertion is missing here in the React 18 test, reducing its ability to catch the specific regression being tested.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/react-regressions/react18/src/suspense-vm-duplicate.test.tsx, line 121:

<comment>The React 19 variant of this test asserts `expect(vmStore.getIds(PageVM)).toHaveLength(0)` during the Suspense fallback state, verifying no premature VM is created. This assertion is missing here in the React 18 test, reducing its ability to catch the specific regression being tested.</comment>

<file context>
@@ -0,0 +1,199 @@
+      routeStore.navigate('page');
+    });
+
+    expect(screen.getByTestId('loading')).toBeDefined();
+
+    await act(async () => {
</file context>

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