fix: replace each loading notification by its own handle - #177
Merged
vuki656 merged 1 commit intoAug 17, 2026
Merged
Conversation
`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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 versionsorFetching 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
package.jsonin a pnpm workspace (apnpm-workspace.yamlabove it), withyqon$PATHand a notification plugin installed (snacks.notifier or nvim-notify). Fetching …notification is replaced by the result message. The other one stays on the screen.actions/show.luastarts two jobs in a workspace —outdated --jsonand 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.luakeeps a queue of instances, but it stores only one notification handle, inM.state.notification.start()overwrites it:stop()andupdate_spinner()both read that single field, so with two instances:Notification 1 is never replaced. It also never reaches the
snacks.hide()call, because that call is guarded by the same field.update_spinnerhas the same problem: it drawsM.state.notificationwith themessagethat its timer closure received in the firstnew(), so the handle and the message can belong to different instances.Every entry in the queue already has its own handle (
instance.notification, set innew()), 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 asreplace(used by nvim-notify) and asid(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 instop()is not needed anymore. I removed it, which is also better because it hid the notifications of other plugins.M.state.notificationis 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.luawith three cases:stopreplaces its own notificationupdate_spinnerupdates every instance with its own message and handleThe tests replace
vim.notifywith a stub that returns and accepts handles, like a real notification backend. To make this possible, the backend detection moved from local variables intoM.__has_notify_backendandM.__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 testpasses (all suites), andstylua --check .is clean.Neovim version: v0.12.4. Notification backend used for testing: snacks.notifier.