Skip to content

Conversation

9romise
Copy link

@9romise 9romise commented Sep 9, 2025

This PR adds support for a new option signal to the watch API in @vue/reactivity.

It accepts an AbortSignal. When provided, the watcher will be stopped once the corresponding AbortController is aborted.

This provides a more flexible way to stop multiple watchers at once by sharing a single AbortController.

Before:

const count = ref(0)

const cb1 = () => {}
const cb2 = () => {}

const stop1 = watch(count, cb1)
const stop2 = watch(count, cb2)

stop1()
stop2()

After:

const count = ref(0)
const controller = new AbortController()

const cb1 = () => {}
const cb2 = () => {}

watch(count, cb1, { signal: controller.signal })
watch(count, cb2, { signal: controller.signal })

controller.abort()

Summary by CodeRabbit

  • New Features

    • Added AbortController-based cancellation for reactive watchers. You can pass a signal to cancel one or multiple watchers at once. Works across effect and source-based watchers, including post and sync variants. Existing behavior remains unchanged unless a signal is provided.
  • Tests

    • Added tests verifying that aborting a shared signal stops all associated watchers and that no further updates occur after cancellation.

Copy link

coderabbitai bot commented Sep 9, 2025

Walkthrough

Adds AbortSignal-based cancellation to reactivity watchers. The watch API now accepts an optional signal to stop a watcher on abort. Runtime-core exposes a BaseWatchEffectOptions with signal and updates watchPostEffect/watchSyncEffect signatures. Tests verify abort-driven cancellation for watch, watchEffect, and multiple watches sharing one signal.

Changes

Cohort / File(s) Summary
Reactivity: AbortSignal support in watch
packages/reactivity/src/watch.ts
Adds signal?: AbortSignal to WatchOptions; registers abort listener to stop the watch handle when signaled. Existing behavior unchanged when no signal provided.
Reactivity tests: watch abort behavior
packages/reactivity/__tests__/watch.spec.ts
Adds test ensuring two watches sharing one AbortSignal are both stopped after abort.
Runtime-core: options surface for watch effects
packages/runtime-core/src/apiWatch.ts
Introduces BaseWatchEffectOptions with optional signal; WatchEffectOptions extends it; updates watchPostEffect and watchSyncEffect signatures to accept BaseWatchEffectOptions.
Runtime-core tests: aborting watchers/effects
packages/runtime-core/__tests__/apiWatch.spec.ts
Adds tests verifying AbortController stops watchEffect and watch watchers from reacting after abort.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Caller
  participant AC as AbortController
  participant W as watch()
  participant E as Reactive Effect
  participant H as WatchHandle

  C->>AC: const { signal } = new AbortController()
  C->>W: watch(source, cb, { signal })
  W->>E: create reactive effect
  W-->>H: return handle (stop)

  Note over E,H: Normal operation
  E-->>C: on source change -> invoke cb

  Note over AC,H: Abort path (new)
  C->>AC: controller.abort()
  AC-->>W: signal "abort" event
  W->>H: H.stop()
  H-->>E: teardown effect (unsubscribe)

  Note over E: After abort: no further cb invocations
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly and accurately describes the primary change, namely adding support for AbortController to the watch API in the reactivity package, and follows the conventional commit format with scope and type.
Description Check ✅ Passed The description clearly outlines the new signal option for the watch API, explains its behavior with AbortController, and provides before-and-after code examples that directly relate to the changeset.

Poem

I twitch my ears at signals’ call,
A whisper: “Abort!”—and I stop, that’s all.
Two watches nap beneath one tree,
One puff of wind, both wander free.
I thump the ground—tests pass, of course! 🐇
Now onward hop, with lighter force.

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
		  - name: "Undocumented Breaking Changes"
			  mode: "warning"
			  instructions: |
				  Flag potential breaking changes that are not documented:
				  1. Identify changes to public APIs/exports, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints (including removed/renamed items and changes to types, required params, return values, defaults, or behavior).
				  2. Ignore purely internal/private changes (e.g., code not exported from package entry points or marked internal).
				  3. Verify documentation exists: a "Breaking Change" section in the PR description and updates to CHANGELOG.md.

Please share your feedback with us on this Discord post.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • 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

@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/reactivity/src/watch.ts (1)

126-126: Handle pre-aborted signals and remove abort listener on manual stop

  • If signal.aborted is already true, stop immediately.
  • When stop() is called manually, remove the abort listener to avoid retaining references.

Apply:

   const watchHandle: WatchHandle = () => {
-    effect.stop()
+    // detach abort listener if present
+    if (signal && 'removeEventListener' in signal) {
+      signal.removeEventListener('abort', watchHandle)
+    }
+    effect.stop()
     if (scope && scope.active) {
       remove(scope.effects, effect)
     }
   }

   if (signal) {
-    signal.addEventListener('abort', watchHandle, { once: true })
+    if (signal.aborted) {
+      watchHandle()
+    } else {
+      signal.addEventListener('abort', watchHandle, { once: true })
+    }
   }

Also applies to: 214-221, 230-233

packages/reactivity/__tests__/watch.spec.ts (1)

293-312: Add a test for pre-aborted signals

Verify that a watcher with an already-aborted signal never runs and detaches immediately. This guards the new fast-path.

Example:

 it('stop multiple watches by abort controller', async () => {
   ...
 })
+
+it('pre-aborted signal stops watch immediately', async () => {
+  const controller = new AbortController()
+  controller.abort()
+  const state = ref(0)
+  const cb = vi.fn()
+  watch(state, cb, { signal: controller.signal, immediate: true })
+  await nextTick()
+  expect(cb).not.toHaveBeenCalled()
+  state.value++
+  await nextTick()
+  expect(cb).not.toHaveBeenCalled()
+})
packages/runtime-core/__tests__/apiWatch.spec.ts (1)

435-475: Good coverage for abort-driven stop

Both effect and source variants are validated. Consider mirroring the pre-aborted case here as well for completeness, but not required.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 75220c7 and 0d5dc3e.

📒 Files selected for processing (4)
  • packages/reactivity/__tests__/watch.spec.ts (1 hunks)
  • packages/reactivity/src/watch.ts (3 hunks)
  • packages/runtime-core/__tests__/apiWatch.spec.ts (1 hunks)
  • packages/runtime-core/src/apiWatch.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
packages/reactivity/__tests__/watch.spec.ts (3)
packages/reactivity/src/watch.ts (1)
  • watch (121-334)
packages/runtime-core/src/apiWatch.ts (1)
  • watch (130-143)
packages/runtime-core/src/scheduler.ts (1)
  • nextTick (61-67)
packages/runtime-core/src/apiWatch.ts (2)
packages/reactivity/src/effect.ts (1)
  • DebuggerOptions (23-26)
packages/runtime-core/src/index.ts (2)
  • DebuggerOptions (222-222)
  • WatchEffectOptions (232-232)
packages/runtime-core/__tests__/apiWatch.spec.ts (2)
packages/runtime-core/src/apiWatch.ts (2)
  • watchEffect (59-64)
  • watch (130-143)
packages/reactivity/src/watch.ts (1)
  • watch (121-334)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Redirect rules
  • GitHub Check: Header rules
  • GitHub Check: Pages changed
🔇 Additional comments (1)
packages/runtime-core/src/apiWatch.ts (1)

68-69: LGTM — signatures widened correctly

watchPostEffect/watchSyncEffect now accept the same base options (including signal) without exposing flush in their types. This matches usage and keeps the API tight.

Also applies to: 79-80

Comment on lines 49 to 51
export interface WatchOptions<Immediate = boolean> extends DebuggerOptions {
signal?: AbortSignal
immediate?: Immediate
Copy link

@coderabbitai coderabbitai bot Sep 9, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid DOM-only typing in @vue/reactivity to prevent TS breakage in non-DOM builds

Referencing AbortSignal directly forces consumers to include lib.dom. Reactivity is often used in Node/SSR without DOM libs, so this becomes a breaking type change. Use a structural "AbortSignal-like" type instead.

Apply:

+export interface WatchAbortSignal {
+  readonly aborted: boolean
+  addEventListener(
+    type: 'abort',
+    listener: () => void,
+    options?: { once?: boolean } | boolean
+  ): void
+  removeEventListener?(
+    type: 'abort',
+    listener: () => void
+  ): void
+}
 
 export interface WatchOptions<Immediate = boolean> extends DebuggerOptions {
-  signal?: AbortSignal
+  signal?: WatchAbortSignal
   immediate?: Immediate
   deep?: boolean | number
   once?: boolean
   scheduler?: WatchScheduler
   onWarn?: (msg: string, ...args: any[]) => void
🤖 Prompt for AI Agents
In packages/reactivity/src/watch.ts around lines 49 to 51, referencing the DOM
AbortSignal type in WatchOptions forces consumers to pull in lib.dom; replace
the DOM-specific type with a minimal structural "AbortSignal-like" interface
(e.g. an exported interface with the properties/methods you actually rely on
such as readonly aborted?: boolean and
addEventListener/removeEventListener/abort optional signatures) and use that
type for signal?: instead of AbortSignal; add the interface in this file (or a
shared types file) and export it if needed so non-DOM builds won’t require
lib.dom.

Copy link
Author

Choose a reason for hiding this comment

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

AbortController and AbortSignal was added in v15.0.0, v14.17.0 in Node.js.

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment on lines +44 to 49
export interface BaseWatchEffectOptions extends DebuggerOptions {
signal?: AbortSignal
}

export interface WatchEffectOptions extends BaseWatchEffectOptions {
flush?: 'pre' | 'post' | 'sync'
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Reuse reactivity’s signal type to avoid hard DOM dependency here too

Tie the options’ signal type to @vue/reactivity’s WatchOptions['signal'] so both packages stay in lockstep and avoid lib.dom requirements.

Apply:

-export interface BaseWatchEffectOptions extends DebuggerOptions {
-  signal?: AbortSignal
-}
+export interface BaseWatchEffectOptions extends DebuggerOptions {
+  signal?: NonNullable<BaseWatchOptions['signal']>
+}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/runtime-core/src/apiWatch.ts around lines 44 to 49, the
BaseWatchEffectOptions currently uses the DOM AbortSignal type; replace that
with the reactivity package's signal type by importing the WatchOptions type
from '@vue/reactivity' (import as a type-only import) and change the signal
property to use WatchOptions['signal'] so the two packages stay in sync and
avoid depending on lib.dom.

Copy link

github-actions bot commented Sep 9, 2025

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 101 kB (+52 B) 38.5 kB (+29 B) 34.7 kB (+24 B)
vue.global.prod.js 159 kB (+52 B) 58.6 kB (+31 B) 52.1 kB (-15 B)

Usages

Name Size Gzip Brotli
createApp (CAPI only) 46.6 kB 18.2 kB 16.7 kB
createApp 54.7 kB (+52 B) 21.3 kB (+24 B) 19.5 kB (+23 B)
createSSRApp 58.9 kB (+52 B) 23 kB (+32 B) 21 kB (+27 B)
defineCustomElement 59.7 kB (+52 B) 22.9 kB (+28 B) 20.9 kB (+35 B)
overall 68.8 kB (+52 B) 26.5 kB (+29 B) 24.1 kB (-15 B)

Copy link

pkg-pr-new bot commented Sep 9, 2025

Open in StackBlitz

@vue/compiler-core

npm i https://pkg.pr.new/@vue/compiler-core@13861

@vue/compiler-dom

npm i https://pkg.pr.new/@vue/compiler-dom@13861

@vue/compiler-sfc

npm i https://pkg.pr.new/@vue/compiler-sfc@13861

@vue/compiler-ssr

npm i https://pkg.pr.new/@vue/compiler-ssr@13861

@vue/reactivity

npm i https://pkg.pr.new/@vue/reactivity@13861

@vue/runtime-core

npm i https://pkg.pr.new/@vue/runtime-core@13861

@vue/runtime-dom

npm i https://pkg.pr.new/@vue/runtime-dom@13861

@vue/server-renderer

npm i https://pkg.pr.new/@vue/server-renderer@13861

@vue/shared

npm i https://pkg.pr.new/@vue/shared@13861

vue

npm i https://pkg.pr.new/vue@13861

@vue/compat

npm i https://pkg.pr.new/@vue/compat@13861

commit: 0d5dc3e

@ferferga
Copy link
Contributor

Can you do the same for effectScope? Or perhaps the implementation of effectScope is enough and watch doesn't need this.

@OrbisK
Copy link
Contributor

OrbisK commented Sep 17, 2025

We should probably benchmark this.

It would be good to have this for effectScope and watch. This would allow us to get the signal with getCurrentScope.

const {signal} = getCurrentScope()

fetch(url, {signal})

Copy link
Member

@antfu antfu left a comment

Choose a reason for hiding this comment

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

I personally would love to see it landed!

@9romise 9romise changed the title feat(reactivity): add support for AbortController to watch feat(watch): add support for AbortController Sep 19, 2025
@9romise
Copy link
Author

9romise commented Sep 19, 2025

We should probably benchmark this.

It would be good to have this for effectScope and watch. This would allow us to get the signal with getCurrentScope.

const {signal} = getCurrentScope()

fetch(url, {signal})

That looks awesome! I’ve opened a new PR for effectScope — I’m not entirely sure if this aligns with what you had in mind, so feel free to take a look and leave your feedback there!

Thank you all for the reviews and suggestions! 💚

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.

5 participants