Skip to content

fix: replace each loading notification by its own handle - #177

Merged
vuki656 merged 1 commit into
vuki656:masterfrom
wmaurer:fix/loading-status-orphaned-notification
Aug 17, 2026
Merged

fix: replace each loading notification by its own handle#177
vuki656 merged 1 commit into
vuki656:masterfrom
wmaurer:fix/loading-status-orphaned-notification

Conversation

@wmaurer

@wmaurer wmaurer commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Problem

In a pnpm workspace, one 󰇚 Fetching … notification stays on the screen after both jobs have finished. Nothing in the plugin can replace or hide it anymore, so the user has to clear it by hand (for example :lua Snacks.notifier.hide()).

Which of the two messages gets stuck depends on which job finishes first, so it can be either Fetching latest versions or Fetching pnpm workspace.

There is a second problem with the same cause: while both jobs are running, the spinner draws both notifications with the message of only one instance, so the second one shows the wrong text.

How to reproduce

  1. Open a package.json in a pnpm workspace (a pnpm-workspace.yaml above it), with yq on $PATH and a notification plugin installed (snacks.notifier or nvim-notify).
  2. Watch the notifications while the plugin loads.
  3. One 󰇚 Fetching … notification is replaced by the result message. The other one stays on the screen.

actions/show.lua starts two jobs in a workspace — outdated --json and the workspace file — and each job creates its own loading instance. That is why the workspace case shows the problem, but any two instances that run at the same time will do it.

Cause

ui/generic/loading-status.lua keeps a queue of instances, but it stores only one notification handle, in M.state.notification. start() overwrites it:

M.start = function(id)
    for _, instance in ipairs(M.queue) do
        if instance.id == id then
            instance.is_ready = true
            M.state.notification = instance.notification  -- the previous handle is lost here
        end
    end
end

stop() and update_spinner() both read that single field, so with two instances:

new(A) -> notification 1 ; start(A) -> state.notification = 1
new(B) -> notification 2 ; start(B) -> state.notification = 2   -- 1 cannot be reached anymore
stop(B) -> replaces 2, sets state.notification = nil
stop(A) -> state.notification is nil, so the whole notify block is skipped

Notification 1 is never replaced. It also never reaches the snacks.hide() call, because that call is guarded by the same field. update_spinner has the same problem: it draws M.state.notification with the message that its timer closure received in the first new(), so the handle and the message can belong to different instances.

Every entry in the queue already has its own handle (instance.notification, set in new()), so the needed information is there. Only the lookups go through the shared field.

Fix

Use the handle of each instance instead of M.state.notification:

  • start(id) only marks its instance as ready.
  • stop(id, message, level) finds its own instance and sends the notification with that instance's handle. The handle is passed as replace (used by nvim-notify) and as id (used by snacks.notifier).
  • update_spinner() goes through the queue and updates every instance with its own message and handle, and stores the returned handle back on the instance.

Because each notification is now replaced by its handle, the general snacks.hide() call in stop() is not needed anymore. I removed it, which is also better because it hid the notifications of other plugins.

M.state.notification is removed. Nothing outside this module read it (get(), which the statusline uses, reads the queue), so the change stays inside the module.

Tests

The module had no tests, so I added lua/package-info/tests/suites/ui/loading_status_spec.lua with three cases:

  • two instances at the same time: each stop replaces its own notification
  • one instance: the result message carries the handle of that instance
  • update_spinner updates every instance with its own message and handle

The tests replace vim.notify with a stub that returns and accepts handles, like a real notification backend. To make this possible, the backend detection moved from local variables into M.__has_notify_backend and M.__is_notifying().

I checked that the first test really catches the bug: when I put the old shared-field logic back, only that case fails, and it fails because the second notification is missing.

make test passes (all suites), and stylua --check . is clean.

Neovim version: v0.12.4. Notification backend used for testing: snacks.notifier.

`loading-status` kept a queue of instances but only a single notification
handle in `M.state.notification`, which `start()` overwrote. With two
overlapping instances - as `show` creates in a pnpm workspace, one for
`outdated --json` and one for the workspace file - the first handle became
unreachable, so its notification was never replaced and stayed on screen
after both jobs had finished. `update_spinner` had the matching problem: it
rendered the shared handle with the message its timer closure captured in
the first `new()`, so the second toast showed the wrong text.

Use the per-instance handle everywhere instead:

- `start` only marks its instance ready
- `stop` looks up its own instance and notifies with that instance's handle,
  passed as `replace` for nvim-notify and `id` for snacks.notifier
- `update_spinner` walks the queue and updates every instance with its own
  message and handle

Replacing by handle also makes the blanket `snacks.hide()` in `stop`
unnecessary, which is worth dropping since it hid other plugins'
notifications too. `M.state.notification` is gone; nothing outside the
module read it.

The backend detection moves to `M.__has_notify_backend` / `M.__is_notifying`
so the notification path can be exercised in tests, which the module had
none of until now.
@vuki656
vuki656 merged commit febe21a into vuki656:master Aug 17, 2026
4 checks passed
@vuki656

vuki656 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Thanks ❤️

wmaurer added a commit to wmaurer/package-info.nvim that referenced this pull request Aug 25, 2026
`new` started the spinner timer with a plain callback, so
`update_spinner`
ran in a fast event context and called `vim.notify` from there.
Notification
backends open and close floating windows in that call - snacks.notifier
does
it synchronously from `add` - which is not allowed in a fast event, and
the
window it opens is dropped before the backend registers it. That leaves
a
`Fetching ...` toast on screen that nothing can close afterwards, since
the
backend has no record of it: `Snacks.notifier.hide()` does not touch it
and
the plugin never held a handle for it either.

This is a separate bug from the lost handle fixed in vuki656#177 and predates
it.
It shows up in a pnpm workspace because `show` starts two jobs there,
and
every buffer switch during loading adds another pair of instances, so
the
16 replaces per second per instance get plenty of chances to race.

Wrap the tick in `vim.schedule_wrap` so every notification is sent from
the
main loop, and bail out of a tick scheduled before `reset_state` closed
the
timer.

Measured on a real pnpm workspace, switching package.json buffers every
250ms while the jobs are in flight, 10 runs each, interleaved: 5/10 runs
left a stranded toast before, 0/10 after.
wmaurer added a commit to wmaurer/package-info.nvim that referenced this pull request Aug 25, 2026
`new` started the spinner timer with a plain callback, so `update_spinner` ran in a fast event context and called `vim.notify` from there. Notification backends open and close floating windows in that call - snacks.notifier does it synchronously from `add` - which is not allowed in a fast event, and the window it opens is dropped before the backend registers it. That leaves a `Fetching ...` toast on screen that nothing can close afterwards, since the backend has no record of it: `Snacks.notifier.hide()` does not touch it and the plugin never held a handle for it either.

This is a separate bug from the lost handle fixed in vuki656#177 and predates it. It shows up in a pnpm workspace because `show` starts two jobs there, and every buffer switch during loading adds another pair of instances, so the 16 replaces per second per instance get plenty of chances to race.

Wrap the tick in `vim.schedule_wrap` so every notification is sent from the main loop, and bail out of a tick scheduled before `reset_state` closed the timer.

Measured on a real pnpm workspace, switching package.json buffers every 250ms while the jobs are in flight, 10 runs each, interleaved: 5/10 runs left a stranded toast before, 0/10 after.
vuki656 pushed a commit that referenced this pull request Aug 25, 2026
`new` started the spinner timer with a plain callback, so `update_spinner` ran in a fast event context and called `vim.notify` from there. Notification backends open and close floating windows in that call - snacks.notifier does it synchronously from `add` - which is not allowed in a fast event, and the window it opens is dropped before the backend registers it. That leaves a `Fetching ...` toast on screen that nothing can close afterwards, since the backend has no record of it: `Snacks.notifier.hide()` does not touch it and the plugin never held a handle for it either.

This is a separate bug from the lost handle fixed in #177 and predates it. It shows up in a pnpm workspace because `show` starts two jobs there, and every buffer switch during loading adds another pair of instances, so the 16 replaces per second per instance get plenty of chances to race.

Wrap the tick in `vim.schedule_wrap` so every notification is sent from the main loop, and bail out of a tick scheduled before `reset_state` closed the timer.

Measured on a real pnpm workspace, switching package.json buffers every 250ms while the jobs are in flight, 10 runs each, interleaved: 5/10 runs left a stranded toast before, 0/10 after.

Co-authored-by: Wayne Maurer <2899448+wmaurer@users.noreply.github.com>
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.

2 participants