Skip to content

Conversation

@ThiloAschebrock
Copy link

@ThiloAschebrock ThiloAschebrock commented May 3, 2025

🎯 Changes

Fixes #9020

The issue was that the effect that subscribes to the observer runs after the observer emits the first state change if the mutation is triggered in the constructor of a component or within ngOnInit. This is fixed replacing effects with computed signals that also handle the subscriptions.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Refactor

    • Reworked internal mutation handling for more efficient observer reuse and stronger cleanup tied to component destruction.
    • Improved pending-task and error propagation behavior while keeping the public API backward compatible.
  • Tests

    • Updated tests for more reliable lifecycle synchronization using async timer advancement and stability checks.
    • Added tests verifying pending state transitions and observable status/error propagation.

@TkDodo TkDodo requested a review from arnoud-dv May 3, 2025 07:32
@ThiloAschebrock ThiloAschebrock force-pushed the bugfix/mutation-skips-pending-state branch from b4722f2 to 5c6a9de Compare May 23, 2025 23:25
@ThiloAschebrock
Copy link
Author

Removed the last effect of injectMutation in 9fcd7dd that was causing scenarios where the mutation function could be out of sync with the provided options.

@changeset-bot
Copy link

changeset-bot bot commented Oct 26, 2025

🦋 Changeset detected

Latest commit: 5da0ab7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@tanstack/angular-query-experimental Minor
@tanstack/angular-query-persist-client Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 26, 2025

Warning

Rate limit exceeded

@ThiloAschebrock has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 4 minutes and 12 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 54aa976 and 5da0ab7.

📒 Files selected for processing (3)
  • .changeset/puny-melons-deny.md (1 hunks)
  • packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (6 hunks)
  • packages/angular-query-experimental/src/inject-mutation.ts (6 hunks)

Walkthrough

Reworks injectMutation internal state and lifecycle: replaces effect-based signals with a single linked result signal, adds DestroyRef cleanup and ngZone-managed subscriptions, introduces pending-task tracking, and updates tests to use async timer advancement and app.whenStable(); adds a test for constructor-triggered mutation pending state.

Changes

Cohort / File(s) Summary
Core mutation injection implementation
packages/angular-query-experimental/src/inject-mutation.ts
Replaces effect-driven signal wiring with a computed-linked result signal, registers DestroyRef cleanup, uses ngZone.runOutsideAngular for observer subscriptions, manages pending tasks and cleanup, consolidates result propagation, and retains public API signatures.
Test suite updates
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts
Removes TestBed.tick() usage; synchronizes with fake timers + app.whenStable() and advanceTimersByTimeAsync; adds test validating pending state when mutation runs in constructor; destructures and asserts status() and error() observables.

Sequence Diagram(s)

sequenceDiagram
    participant C as Component
    participant Z as ngZone
    participant M as MutationObserver
    participant S as linkedResultSignal

    rect rgba(220,240,255,0.35)
      Note over C,S: Previous (effect-based) flow
      C->>M: trigger mutate() (constructor/ngOnInit)
      M-->>S: synchronous update (subscription timing race)
      Note right of S: pending state may be missed
    end

    rect rgba(220,255,220,0.35)
      Note over C,S: New (lifecycle-aware) flow
      C->>Z: trigger mutate() (constructor/ngOnInit)
      Z->>Z: runOutsideAngular queues observer subscription
      Z->>S: set pending task immediately
      Z->>M: subscribe (post-init) and observe results
      M-->>Z: onComplete
      Z->>S: clear pending / propagate final result
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Pay attention to signal composition and reactivity correctness in inject-mutation.ts.
  • Verify DestroyRef teardown and ngZone subscription boundaries (runOutsideAngular vs run).
  • Validate pending-task lifecycle and test timing changes in inject-mutation.test.ts.

Possibly related PRs

Suggested reviewers

  • arnoud-dv

Poem

🐰 In constructor's dusk a mutation stirred,

Pending was hidden — no longer blurred.
DestroyRef tended the signal's bloom,
ngZone queued calm, escaped the gloom.
Hooray — tests now wait, then celebrate the tune.

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Title Check ✅ Passed The title "fix(angular-query): ensure initial mutation pending state is emitted" is clear, concise, and directly summarizes the main objective of the PR. It uses the conventional "fix" prefix, specifies the affected package (angular-query), and precisely describes what was fixed (ensuring pending state is emitted in early lifecycle hooks). The title accurately reflects the core problem being addressed in the changeset—that mutations triggered in constructors or ngOnInit were not showing their pending state.
Linked Issues Check ✅ Passed The code changes directly address the requirements from linked issue #9020. The implementation adds a new test ("should have pending state when mutating in constructor") that reproduces and validates the exact scenario described in the issue—mutations triggered in component constructors and ngOnInit now properly emit the pending state. The main fix replaces the effect-based subscription pattern (which was delayed relative to the observer's initial emit) with an immediate subscription via linkedResultSignal, ensuring the initial pending state is captured. The refactoring of the subscription architecture, pending task management, and destroy cleanup all support fixing the core issue where mutations were incorrectly appearing as idle when triggered in early lifecycle hooks.
Out of Scope Changes Check ✅ Passed All code changes in this PR are scoped to addressing the core issue in #9020. The test additions directly validate the fix for mutations in constructors and early lifecycle hooks; the replacement of effect-based subscriptions with an immediate subscription pattern fixes the root cause; pending task management and destroy cleanup support proper lifecycle handling; and adjustments to timer synchronization in tests are necessary to validate the new behavior. While the signal architecture refactoring is comprehensive, it is justified by the need to remove the effect-based delay that caused the initial pending state to be missed. No changes appear to address unrelated concerns or introduce functionality outside the scope of fixing the pending state emission issue.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed The pull request description includes all required sections from the template: the 🎯 Changes section clearly explains the issue and fix with reference to the specific bug report; the ✅ Checklist section shows both items properly marked as completed, confirming the author followed the Contributing guide and tested the code locally; and the 🚀 Release Impact section correctly indicates this affects published code and a changeset has been generated. The description is well-structured, provides sufficient detail about the problem (effect timing issue in constructor/ngOnInit), the solution (replacing effects with computed signals), and all required information is present and complete.

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/angular-query-experimental/src/inject-mutation.ts (2)

137-145: Consider setting state before throwing to preserve observable state.

Today, when throwOnError triggers, you throw before result.set(state), so consumers might not observe the error state update. Optionally set the state first, then throw in a microtask to keep behavior while maintaining state visibility.

-              if (
-                state.isError &&
-                shouldThrowError(observer.options.throwOnError, [state.error])
-              ) {
-                ngZone.onError.emit(state.error)
-                throw state.error
-              }
-
-              result.set(state)
+              if (state.isError) {
+                result.set(state)
+                if (shouldThrowError(observer.options.throwOnError, [state.error])) {
+                  queueMicrotask(() => {
+                    ngZone.run(() => ngZone.onError.emit(state.error))
+                    throw state.error
+                  })
+                  return
+                }
+                return
+              }
+              result.set(state)

If you prefer current semantics, feel free to keep as‑is.


94-101: Make non‑throwing mutate intent explicit.

Prefix with void so the returned promise isn’t accidentally relied upon by callers.

-    return (variables, mutateOptions) => {
-      observer.mutate(variables, mutateOptions).catch(noop)
-    }
+    return (variables, mutateOptions) => {
+      void observer.mutate(variables, mutateOptions).catch(noop)
+    }
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (1)

392-421: Harden the constructor‑pending test against flakiness.

Use async timers and trigger change detection before assertions to avoid timing races.

-    vi.advanceTimersByTime(1)
-    expect(span.nativeElement.textContent).toEqual('pending')
+    await vi.advanceTimersByTimeAsync(1)
+    fixture.detectChanges()
+    expect(span.nativeElement.textContent).toEqual('pending')

Optionally add a sibling test for ngOnInit to mirror the reported scenario.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38b4008 and a058bf7.

📒 Files selected for processing (2)
  • packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (1 hunks)
  • packages/angular-query-experimental/src/inject-mutation.ts (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (1)
packages/angular-query-experimental/src/inject-mutation.ts (1)
  • injectMutation (45-182)
packages/angular-query-experimental/src/inject-mutation.ts (3)
packages/query-core/src/mutationObserver.ts (2)
  • MutationObserver (23-211)
  • state (145-159)
packages/angular-query-experimental/src/pending-tasks-compat.ts (1)
  • PendingTaskRef (7-7)
packages/query-core/src/utils.ts (1)
  • shouldThrowError (446-456)

@ThiloAschebrock ThiloAschebrock force-pushed the bugfix/mutation-skips-pending-state branch from 1d86a66 to 437f509 Compare October 26, 2025 01:41
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (1)

390-419: Consider using async timer advancement for consistency.

The test uses vi.advanceTimersByTime(1) (synchronous) on line 412, while most other tests use await vi.advanceTimersByTimeAsync(...). In zoneless change detection mode, using the async version ensures that all async operations, including change detection updates, have completed before asserting.

Apply this diff for consistency:

-    vi.advanceTimersByTime(1)
+    await vi.advanceTimersByTimeAsync(1)
     expect(span.nativeElement.textContent).toEqual('pending')
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d86a66 and 437f509.

📒 Files selected for processing (2)
  • packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (8 hunks)
  • packages/angular-query-experimental/src/inject-mutation.ts (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (1)
packages/angular-query-experimental/src/inject-mutation.ts (1)
  • injectMutation (44-182)
packages/angular-query-experimental/src/inject-mutation.ts (2)
packages/query-core/src/mutationObserver.ts (2)
  • MutationObserver (23-211)
  • state (145-159)
packages/query-core/src/utils.ts (2)
  • noop (82-82)
  • shouldThrowError (446-456)
🔇 Additional comments (6)
packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts (2)

425-443: Verify signal access pattern for destructured properties.

The test uses status() (calling it as a signal) but error (without calling). If error is also a signal from the proxied result, it should be called as error() for consistency. Please verify whether the destructured error property requires the call operator.

If both are signals, apply this diff:

     expect(status()).toBe('error')
-    expect(error).toBe(err)
+    expect(error()).toBe(err)

Similar changes needed at lines 460 and 477.


569-570: LGTM: Proper async synchronization.

The use of await app.whenStable() after timer advancement correctly ensures that all pending tasks (including mutations) complete before assertions. This pattern aligns well with the new pending task tracking introduced in the fix.

packages/angular-query-experimental/src/inject-mutation.ts (4)

81-89: LGTM: Efficient observer instance reuse.

The updated logic correctly reuses a single MutationObserver instance and calls setOptions() when options change, rather than recreating the observer. This improves efficiency while maintaining reactivity.


98-98: LGTM: Proper promise handling.

Using void observer.mutate(...).catch(noop) correctly prevents unhandled promise rejections for the fire-and-forget mutation pattern.


112-162: Excellent fix: Initial pending state now captured correctly.

The linkedResultSignal implementation resolves the root cause by:

  1. Line 120: Immediately seeding pendingTaskRef when currentResult.isPending is true—this ensures that mutations triggered in constructor/ngOnInit don't miss the pending state.
  2. Line 119: Cleaning up previous subscription before creating a new one, preventing leaks.
  3. Lines 122-149: Subscribing outside Angular zone for performance while updating state inside the zone for change detection.

This addresses the past review comment about seeding the pending task reference.

Based on learnings (past review comments).


174-174: LGTM: Cleanup properly invoked on destroy.

Wrapping cleanup() in a lambda ensures the latest cleanup implementation is invoked when the component is destroyed, rather than capturing the initial noop reference. This addresses the past review comment about preventing resource leaks.

Based on learnings (past review comments).

@ThiloAschebrock ThiloAschebrock force-pushed the bugfix/mutation-skips-pending-state branch 3 times, most recently from 1d23c78 to 840b44f Compare October 26, 2025 02:01
@ThiloAschebrock
Copy link
Author

Hi @arnoud-dv,
resolved conflicts and aligned my changes with your approach of mimicking linked signal of #9808.

I would appreciate if you find some time to review my proposed changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Angular: injectMutation skips pending state when triggered in constructor or ngOnInit

2 participants