feat: Add session$destroy() to remove all module reactivity#4372
feat: Add session$destroy() to remove all module reactivity#4372schloerke wants to merge 41 commits into
session$destroy() to remove all module reactivity#4372Conversation
Port of posit-dev/py-shiny#2209 — adds session$destroy() and session$onDestroy() to clean up dangling reactivity when dynamic module UI is removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests that verify weakref GC semantics work correctly with destroy callbacks for ReactiveVal, Observable, and Observer. Also fix two bugs discovered by the tests: 1. Inline closures in initialize() captured the entire enclosing environment (including `self`/`private`), preventing GC. Fixed by extracting to make_weak_destroy_wrapper() helper. 2. R's lazy evaluation meant the `wr` argument to the helper was a promise retaining a reference to initialize()'s execution env (which holds `self`). Fixed by adding force(wr). 3. Storing self$destroy as the weakref value created a strong reference cycle. Fixed by using wref_key() instead of wref_value() and calling obj$destroy() on the retrieved key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes all keys matching a namespace prefix from the reactive values store, invalidates their dependents, and notifies names/list watchers. This enables cleanup of module inputs when a module scope is destroyed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nySession Adds destroyCallbacksByNs (Map of namespace -> Callbacks) to both ShinySession and MockShinySession, with public onDestroy()/destroy() methods and private helpers for namespace-scoped callback invocation and resource cleanup (inputs, outputs, downloads, files). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ssion docs for onDestroy/destroy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an explicit module-scope teardown API to Shiny sessions to eliminate “dangling reactivity” when dynamic module UI is removed, with reactive primitives participating automatically via weakly-registered destroy hooks.
Changes:
- Introduces
session$destroy()/session$onDestroy()for module session proxies, backed by namespace-keyed destroy callback infrastructure on the root session. - Adds destroy semantics/guards to core reactive primitives (
ReactiveVal,Observable,Observer) plusReactiveValues$_destroy()for namespace cleanup. - Adds comprehensive destroy-focused test coverage and documentation/NEWS updates.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
R/shiny.R |
Implements destroy callback registry on ShinySession, adds proxy onDestroy()/destroy(), and invokes destroy on websocket close. |
R/reactives.R |
Adds destroyed error condition + destroy methods and weak onDestroy registration for reactive primitives; adds ReactiveValues$_destroy(). |
R/mock-session.R |
Adds destroy callback infra and proxy destroy support for MockShinySession to enable testing. |
tests/testthat/test-destroy.R |
New unit tests covering destroyed guards, idempotency, weakref behavior, and module-scope teardown ordering. |
man/session.Rd |
Documents session$onDestroy() and session$destroy(). |
man/insertUI.Rd |
Documents how session$destroy() relates to removeUI() for module cleanup. |
R/insert-ui.R |
Adds roxygen section describing module cleanup with session$destroy(). |
man/MockShinySession.Rd |
Documents new MockShinySession$onDestroy() / $destroy(). |
NEWS.md |
Announces new session destroy APIs and behavior. |
.gitignore |
Ignores docs/ and .context. |
.Rbuildignore |
Excludes docs and .context from package builds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Use ns.sep constant instead of hardcoded "-" in destroy logic - Sort root sentinel __root__ last during full-session teardown - Use rlang::names2() for NULL-safe output name lookup - Clean up output-related clientData entries (output_<ns>-* pattern) - MockShinySession: delegate close() to invokeDestroyCallbacks for proper deepest-first ordering and input cleanup - Always clean up inputs/outputs/routes even when no destroy callbacks are registered (remove early return before cleanup) - Rename misleading weakref test description - Add tests for close ordering, input cleanup, and root-last behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…I docs - Add "Module lifecycle and composability" section to ?session with data ownership examples (return-from-module pitfall vs pass-in pattern) - Add "Destroying module reactivity" section to ?moduleServer - Update ?removeUI to cross-reference ?session composability docs - Clarify destroy() invokes onDestroy callbacks, with cleanup as consequence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous approach used list length as the ID, which could collide after an unsubscribe shrinks the list. A monotonically increasing counter ensures unique IDs across register/unregister cycles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Moves setBookmarkExclude/getBookmarkExclude from warn-noops to real implementations on MockShinySession. Adds registerBookmarkExclude to MockShinySession$makeScope so it mirrors ShinySession, including cleanup on scope$destroy(). Adds tests verifying module bookmark-exclude registrations are cleaned up on destroy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
createMockDomain now supports onDestroy() and destroy() alongside the existing onEnded/end lifecycle, matching ShinySession's contract. This allows invalidateLater() and reactive primitives to register destroy callbacks with custom domains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
makeScope() now throws an error if the namespace is '..root', which is reserved as the sentinel key for root-level destroy callbacks. Prevents collision between root and module destroy callback registries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The example now shows returning a cleanup function from the module rather than calling session$destroy() unconditionally at startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The observer's onDestroy weak callback is now managed by setAutoDestroy() alongside the existing onEnded callback. This ensures observers with autoDestroy=FALSE are not torn down when the domain is destroyed (matching the existing onEnded behavior). createMockDomain$end() now correctly calls destroy() after ended callbacks, matching ShinySession$wsClosed(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The onEnded callback now also clears the onDestroy registration when session closes, completing the cross-deregistration pattern. Without this, the onDestroy callback would leak after session close. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ReactiveValues now auto-registers a weak destroy callback with the reactive domain (like ReactiveVal and Observable), gets destroyed guards on get/set, and gains a full destroy() method. Renamed internal _destroy/_destroyed/_destroyHandle to destroyByPrefix/.destroyed/ .destroyHandle to follow R conventions since users don't use this class directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
session$destroy() to remove all module reactivity
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… and empty test - Fix removeUI example to return cleanup handle from module instead of calling session$destroy() inside the module body - Clarify onDestroy docs: root session callbacks fire on session close (after onEnded); module session callbacks fire on session$destroy() - Wrap empty test body in expect_no_error() to satisfy testthat 3e Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…09-port # Conflicts: # NEWS.md # R/modules.R # man/MockShinySession.Rd # man/moduleServer.Rd
…09-port # Conflicts: # NEWS.md
…udio/shiny into schloerke/py-shiny-2209-port
|
Really like how this turned out — centralizing destroy state on the root session and keying it by fully-qualified namespace makes the whole thing compose cleanly, and the nested-scope handling just falls out of that design. 👍 One thing I wanted to talk through before the docs calcify: the public entry point we're steering people toward. The session/ list(result = ..., destroy = session$destroy)
# parent:
mod <- myModuleServer("editor")
mod$destroy()But because destroy is keyed by namespace (not by proxy object), the parent can also just destroy by id without any handle threading: myModuleServer("editor") # no handle captured
session$makeScope("editor")$destroy() # tears down the same scopeI confirmed (B) actually destroys the original scope's reactives — it works today because of how you built it. What gives me pause about leading with (A):
The mental model that feels most natural to me is the parent owns the child's lifecycle: it inserted the UI under an id, it removes the UI, it destroys the server under the same id. That'd argue for a first-class, symmetric entry point — something like I don't think this blocks the core mechanism — it's really about which path we make canonical in the docs. Curious whether you considered a parent-driven |
Also, most people don't know about |
|
Do you view it as an anti-pattern to destroy a module from the parent session? My initial reaction is that this would be the more common pattern, which is why I think a more ergonomic API would make sense.
I see what you mean, but it also feels a little bit odd to me to motivate a harder-to-discover API based on this. The fact that we have a method for making scope also argues to me that it's sensible to one for the reverse operation. That said, maybe it is a little bit weird considering that |
|
After chatting, we think |
|
Hi! I'm really looking forward to this feature. Thanks for taking the time to implement it! Have you considered triggering This may not make sense if you're trying to keep the R and Python APIs aligned. And I can always write a wrapper that does this, so I'll be happy anyways :) |
Summary
Port of posit-dev/py-shiny#2209 — adds
session$destroy()andsession$onDestroy()to R Shiny.session$destroy()on module session proxies to destroy all reactive state (values, expressions, observers, inputs, outputs) for a module scope and its descendantssession$onDestroy(callback)to register cleanup callbacks that fire on destroyReactiveVal,Observable,Observer,ReactiveValues) auto-register via weak references during init, so destruction is automatic and comprehensiveReactiveValuesgains a fulldestroy()method with domain auto-registration, destroyed guards onget()/set(), and adestroyByPrefix()method for namespace-scoped cleanupinvalidateLater()timers are cancelled on both session end and module destroy, with cross-deregistration to prevent leaksonDestroyregistration respects theautoDestroyflagshiny.destroyed.errorcreateMockDomain()supportsonDestroy/destroylifecycle (destroy fires onend())..rootis rejected inmakeScope()registerBookmarkExclude()returns an unsubscribe handle, cleaned up on module destroyCloses #2281 — The canonical issue:
insertUI()/callModule()to add,removeUI()/??? to remove. No mechanism to deactivate a module server instance.Closes #825 — "Reactive subDomains" proposal (2016). Proposes
createSubDomain()where ending the subdomain destroys all reactive objects created within it.Related to #2374 — Request to delete server-side input values on
removeUI(). Fix is to wrap the UI component into a module. Then removal of all reactivity (not just the input) becomes:mod_session$destroy().Key design decisions
rlang::new_weakref()allow reactive objects to be GC'd before explicitdestroy()— matching py-shiny semanticsShinySession$destroy()throws an error — only module session proxies can be destroyed; root session usesclose()Mapkeyed by namespace stringmake_weak_destroy_wrapper()helper avoids closure environment leaks (discovered via GC tests)ReactiveValuesdomain auto-registration — user-createdreactiveValues()inside a reactive domain auto-register for destroy (likereactiveVal); session-level stores (input/clientData) usedestroyByPrefix()for namespace-scoped cleanup insteadinvalidateLater()—onEndedandonDestroycallbacks each deregister the other when they fire, preventing double-cleanup and leaked handlesFiles changed
R/reactives.RdestroyedReactiveError,ReactiveVal$destroy(),Observable$destroy(), Observer weak registration withautoDestroygating,ReactiveValues$destroy()+destroyByPrefix()+ domain auto-registration,make_weak_destroy_wrapper()R/shiny.RShinySessiondestroy infrastructure,makeScope()proxy overrides,wsClosed()integration,registerBookmarkExclude()unsubscribe handle,..rootnamespace guardR/reactive-domains.RcreateMockDomain()withonDestroy/destroysupportR/mock-session.RMockShinySessiondestroy support, bookmark-exclude real implementations,..rootnamespace guardR/insert-ui.R@sectiononremoveUIfor destroy usageR/modules.R@sectiononmoduleServerfor destroy usage, realistic docs exampletests/testthat/test-destroy.Rtests/testthat/test-mock-session.Rtests/testthat/test-test-server.R🤖 Generated with Claude Code