Skip to content

Conversation

@dummdidumm
Copy link

@dummdidumm dummdidumm commented Oct 25, 2025

🎯 Changes

Svelte 5 now supports async await in components. One behavior there is that when a component is created as part of a boundary that has pending async work, its $effects will not run until that async work is done.

The way the code is written right now means you can end up in an infinite pending state: If you do await someQuery.promise, the $effect for the subscription will never run, and therefore the await will never resolve. This PR fixes it.

✅ 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

  • Bug Fixes
    • Restored reliable support for async Svelte so queries behave correctly in modern Svelte environments.
    • Improved query lifecycle and subscription handling to ensure consistent result updates and proper cleanup across server and client lifecycles.

Svelte 5 now supports async await in components. One behavior there is that when a component is created as part of a boundary that has pending async work, its `$effect`s will not run until that async work is done.

The way the code is written right now means you can end up in an infinite pending state: If you do `await someQuery.promise`, the `$effect` for the subscription will never run, and therefore the `await` will never resolve. This PR fixes it.
@changeset-bot
Copy link

changeset-bot bot commented Oct 25, 2025

🦋 Changeset detected

Latest commit: 59a1acc

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

This PR includes changesets to release 3 packages
Name Type
@tanstack/svelte-query Patch
@tanstack/svelte-query-devtools Patch
@tanstack/svelte-query-persist-client Patch

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 25, 2025

Walkthrough

Replaces the previous $effect-based subscription in createBaseQuery.svelte.ts with an eagerly-initialized, SSR-aware unsubscribe and a watchChanges re-subscription path that calls observer.updateResult() when needed; adds onDestroy cleanup. Adds a patch changeset entry for the release.

Changes

Cohort / File(s) Change Summary
Changeset entry
​.changeset/thin-bugs-change.md
Adds a patch changeset for @tanstack/svelte-query documenting: "fix: support async Svelte". No code/API changes in that file.
Lifecycle / subscription refactor
packages/svelte-query/src/createBaseQuery.svelte.ts
Replaces $effect-based subscription with eager, SSR/client-aware unsubscribe initialization; introduces watchChanges to re-subscribe when isRestoring or observer change, calls observer.updateResult() on re-subscription, and registers onDestroy cleanup with try-catch for non-component contexts.

Sequence Diagram

sequenceDiagram
    participant Component as Svelte Component
    participant WatchChanges as watchChanges
    participant Observer
    participant OnDestroy as onDestroy

    rect rgb(245,245,255)
    Note over Component,Observer: Previous ($effect) pattern
    Component->>Observer: $effect establishes subscription
    Observer-->>Component: returns unsubscribe
    end

    rect rgb(235,250,235)
    Note over Component,WatchChanges: New pattern (eager init + watchChanges + onDestroy)
    Component->>Observer: initialize unsubscribe (eager, SSR-aware)
    Component->>WatchChanges: observe isRestoring / observer changes
    alt Re-subscribe needed
        WatchChanges->>Observer: call observer.updateResult()
        WatchChanges->>Observer: subscribe -> return new unsubscribe
    end
    Component->>OnDestroy: register cleanup
    OnDestroy->>Observer: call unsubscribe on destroy (safe try/catch)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Inspect SSR vs client branching and restoration-state logic.
  • Verify unsubscribe re-initialization correctness and edge cases where observer.updateResult() must run.
  • Check the try-catch on onDestroy to ensure no silent failures in non-component contexts.

Suggested reviewers

  • TkDodo

Poem

🐰 I twitch my whiskers at subscriptions new,
I re-subscribe and wake the watcher too,
I guard the teardown with a careful sigh,
And hop away clean when onDestroy says goodbye. 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "fix: support async Svelte" directly aligns with the main objective of the changeset and code modifications. The raw summary confirms the change adds "fix support for async Svelte," and the PR description details the specific problem with Svelte 5 async components where $effect callbacks are deferred. The title is concise, uses the conventional "fix:" prefix, and clearly communicates the primary purpose to a developer reviewing history without requiring them to read the full description.
Description Check ✅ Passed The PR description comprehensively follows the repository template with all required sections properly completed. The Changes section provides detailed context about the Svelte 5 async behavior issue and explains why the fix is necessary. The Checklist section confirms both contributing guide compliance and local testing have been completed. The Release Impact section correctly indicates this change affects published code with a changeset generated, aligning with the raw summary showing a patch version bump entry.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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: 0

🧹 Nitpick comments (3)
.changeset/thin-bugs-change.md (1)

1-5: Changelog entry OK; consider clarifying scope.

Looks good for a patch bump. Optionally expand the body to mention “Svelte 5 async boundaries/$effect deferral” and that it fixes a potential infinite pending state, to aid readers scanning releases.

packages/svelte-query/src/createBaseQuery.svelte.ts (2)

1-12: Fix import order to satisfy lint (external before local).

Place svelte import before relative (“./…”) imports.

-import { useIsRestoring } from './useIsRestoring.js'
-import { useQueryClient } from './useQueryClient.js'
-import { createRawRef } from './containers.svelte.js'
-import { watchChanges } from './utils.svelte.js'
-import type { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'
-// types...
-import { onDestroy } from 'svelte'
+import { onDestroy } from 'svelte'
+import type { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'
+import { useIsRestoring } from './useIsRestoring.js'
+import { useQueryClient } from './useQueryClient.js'
+import { createRawRef } from './containers.svelte.js'
+import { watchChanges } from './utils.svelte.js'
+// types...

75-94: The web search doesn't specifically address QueryObserver idempotency. Let me search the query-core source to check the unsubscribe implementation.Good! I found evidence from the Andri Purnomo blog about how QueryObserver.subscribe() works. It shows the unsubscribe pattern: the subscribe method adds a listener and returns a function that removes it. Now let me verify the actual QueryObserver implementation by searching the repository code more specifically to understand unsubscribe idempotency.

Based on the search output and the code patterns I've seen:

  1. QueryObserver.subscribe() returns a function that removes the listener from the this.listeners set
  2. The unsubscribe function appears to be created each time, making it callable multiple times without side effects
  3. The pattern listeners.delete(listener) is idempotent - calling delete on a set is safe to call multiple times
  4. Similar patterns exist across other framework integrations (Vue, Solid, React all show the same pattern)

The code review comment is asking for:

  1. Extract a shared noop function (for readability and consistency)
  2. Type unsubscribe explicitly as () => void
  3. Confirm idempotency

Based on the evidence from the codebase searches and the widespread use of this pattern in other framework packages, the unsubscribe function IS idempotent. The review suggestions are valid improvements for code clarity and maintainability.

Final Response:

Extract noop constant and type unsubscribe explicitly for clearer intent and maintainability.

The unsubscribe function from observer.subscribe() is idempotent—calling it multiple times is safe. The review suggestions improve readability by:

  1. Using a shared noop constant instead of inline () => undefined
  2. Explicitly typing unsubscribe as () => void

These changes align with patterns already used throughout the framework integrations (Vue Query line 315, Solid Query line 294).

-  let unsubscribe =
-    isRestoring.current && typeof window !== 'undefined'
-      ? () => undefined
-      : observer.subscribe(() => update(createResult()))
+  const noop: () => void = () => {}
+  let unsubscribe: () => void =
+    isRestoring.current && typeof window !== 'undefined'
+      ? noop
+      : observer.subscribe(() => update(createResult()))
@@
   watchChanges(
     () => [isRestoring.current, observer] as const,
     'pre',
     () => {
       unsubscribe()
-      unsubscribe = isRestoring.current
-        ? () => undefined
+      unsubscribe = isRestoring.current
+        ? noop
         : observer.subscribe(() => update(createResult()))
       observer.updateResult()
       return unsubscribe
     },
   )
📜 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 ba66d67.

📒 Files selected for processing (2)
  • .changeset/thin-bugs-change.md (1 hunks)
  • packages/svelte-query/src/createBaseQuery.svelte.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/svelte-query/src/createBaseQuery.svelte.ts (1)
packages/svelte-query/src/utils.svelte.ts (1)
  • watchChanges (17-44)
🪛 ESLint
packages/svelte-query/src/createBaseQuery.svelte.ts

[error] 11-11: svelte import should occur before import of ./useIsRestoring.js

(import/order)

🪛 LanguageTool
.changeset/thin-bugs-change.md

[grammar] ~1-~1: Hier könnte ein Fehler sein.
Context: --- '@tanstack/svelte-query': patch --- fix: support async Svelte

(QB_NEW_DE)


[grammar] ~5-~5: Hier könnte ein Fehler sein.
Context: ...y': patch --- fix: support async Svelte

(QB_NEW_DE)

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 (2)
packages/svelte-query/src/createBaseQuery.svelte.ts (2)

83-94: Avoid double-unsubscribe across effect cycles.

watchChanges runs the previous cleanup before invoking the next effect; calling unsubscribe() again at the start of the effect can double-call the prior unsubscribe. Often idempotent, but not guaranteed. Guard it and return a wrapped cleanup to mark when cleanup already ran.

+  let cleanupRan = false
   watchChanges(
     () => [isRestoring.current, observer] as const,
     'pre',
     () => {
-      unsubscribe()
-      unsubscribe = isRestoring.current
-        ? () => undefined
-        : observer.subscribe(() => update(createResult()))
-      observer.updateResult()
-      return unsubscribe
+      const prevUnsub = unsubscribe
+      if (!cleanupRan) prevUnsub()
+      unsubscribe = isRestoring.current
+        ? () => undefined
+        : observer.subscribe(() => update(createResult()))
+      observer.updateResult()
+      const currentUnsub = unsubscribe
+      cleanupRan = false
+      return () => {
+        cleanupRan = true
+        currentUnsub()
+      }
     },
   )

96-98: Optional: make cleanup idempotent across effect cleanup and onDestroy.

onDestroy may run after the effect’s cleanup, causing another unsubscribe call. Consider a small wrapper that ensures at-most-once cleanup, then use it both in the effect’s returned cleanup and here.

// place near the subscribe setup
let didUnsub = false
const safeUnsubscribe = () => {
  if (!didUnsub) {
    didUnsub = true
    unsubscribe()
  }
}
-  onDestroy(() => {
-    unsubscribe()
-  })
+  onDestroy(() => {
+    safeUnsubscribe()
+  })
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ba66d67 and 1cacfcb.

📒 Files selected for processing (1)
  • packages/svelte-query/src/createBaseQuery.svelte.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/svelte-query/src/createBaseQuery.svelte.ts (1)
packages/svelte-query/src/utils.svelte.ts (1)
  • watchChanges (17-44)
🔇 Additional comments (2)
packages/svelte-query/src/createBaseQuery.svelte.ts (2)

1-1: LGTM: onDestroy import enables SSR cleanup.

Needed for server-side lifecycle cleanup.


75-81: Verify whether eager subscription logic correctly gates SSR vs. client hydration.

The code conditionally skips subscription only when isRestoring.current && typeof window !== 'undefined' are both true—this avoids re-subscription during client hydration but always subscribes during SSR. However, createQueries.svelte.ts uses simpler logic (only checking isRestoring.current without the window gate), suggesting possible inconsistency.

Confirm: (1) whether useIsRestoring().current can ever be true during server-side rendering, and (2) whether the window check is intentional or should be removed to match the pattern in createQueries.svelte.ts.

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 (2)
packages/svelte-query/src/createBaseQuery.svelte.ts (2)

83-94: Avoid double-unsubscribe on re-subscription

watchChanges returns a cleanup that runs before the next effect. Because the effect also calls unsubscribe() at the start, the previous unsubscribe is invoked twice on subsequent runs. Likely harmless, but avoidable. Do a one-time unsubscribe to clear the initial eager subscription, then rely on cleanup thereafter.

Apply:

-  watchChanges(
-    () => [isRestoring.current, observer] as const,
-    'pre',
-    () => {
-      unsubscribe()
-      unsubscribe = isRestoring.current
-        ? () => undefined
-        : observer.subscribe(() => update(createResult()))
-      observer.updateResult()
-      return unsubscribe
-    },
-  )
+  let didInitialUnsubscribe = false
+  watchChanges(
+    () => [isRestoring.current, observer] as const,
+    'pre',
+    () => {
+      if (!didInitialUnsubscribe) {
+        unsubscribe()
+        didInitialUnsubscribe = true
+      }
+      unsubscribe = isRestoring.current
+        ? () => undefined
+        : observer.subscribe(() => update(createResult()))
+      observer.updateResult()
+      return unsubscribe
+    },
+  )

Please confirm QueryObserver’s unsubscribe is idempotent. If not, this refactor becomes essential.


99-102: Optional: extract a safeOnDestroy helper

The try/catch pattern is fine. Consider a tiny util to reduce repetition and improve intent:

// utils
export const safeOnDestroy = (fn: () => void) => { try { onDestroy(fn) } catch {} }

// usage
safeOnDestroy(() => unsubscribe())
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1cacfcb and 59a1acc.

📒 Files selected for processing (1)
  • packages/svelte-query/src/createBaseQuery.svelte.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/svelte-query/src/createBaseQuery.svelte.ts (1)
packages/svelte-query/src/utils.svelte.ts (1)
  • watchChanges (17-44)
🔇 Additional comments (2)
packages/svelte-query/src/createBaseQuery.svelte.ts (2)

1-1: Import of onDestroy looks good

Needed for SSR-safe teardown below. No concerns.


77-81: Eager subscribe/SSR guard is correct

Subscribes on SSR and suspended client branches; skips during client restoring. This addresses the deferred $effect in Svelte 5.

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.

1 participant