Skip to content

Let plugin routes opt in to keep-alive - #521

Open
jaysuk wants to merge 2 commits into
Duet3D:v3.7-devfrom
jaysuk:feature/plugin-route-keep-alive
Open

jaysuk wants to merge 2 commits into
Duet3D:v3.7-devfrom
jaysuk:feature/plugin-route-keep-alive

Conversation

@jaysuk

@jaysuk jaysuk commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Lets a plugin's pages be kept alive while the user visits other pages, the way DWC's own Explorer already is.

  • registerRoute() takes keepAlive?: boolean | string. true uses the component's own name (name, or the __name a <script setup> SFC gets from its file); a string names it explicitly and must equal the component's name. It is stored in meta.keepAlive, the field the file-based pages already use in their <route> block, so DwcRouterView and a custom layout's own router view treat both alike. A route whose component has no matching name is not cached and DWC logs a warning (an include that matches nothing would otherwise silently cache nothing, or the wrong component).
  • DwcRouterView reads the names reactively. It used to snapshot router.getRoutes() once at setup, so a route registered afterwards (a plugin loading late, or a page created at runtime) could never be cached. plugins/index.ts now keeps a reactive name -> count map (counted because one component can serve several routes), getKeepAliveNames() exposes it, and unregisterRoute() withdraws the request.
  • The route override dispatcher keeps the page's keep-alive. RouteOverrideDispatcher hid the page behind it from <keep-alive>, so overriding the Explorer (registerLayout({ routes })) stopped the stock page being cached. The dispatcher now takes the name in meta.keepAlive when the page has one (a string, so it works even while the page is still a lazy loader), and the override is cached exactly when the page would have been.
  • Docs: PLUGINS.md (registerRoute) and CUSTOM-LAYOUT.md (overrides).

Off by default; nothing changes for a route that doesn't ask.

Motivation

A plugin that shows an editor (or any page holding state the user would be upset to lose) currently loses it on every trip to another page, and there is no supported way to avoid that: registerRoute has no keep-alive option, the include list was fixed at setup, and an overridden page lost its own caching. Flexible Layouts works around this today by moving its Explorer's open tabs and unsaved buffers into a module-level store; with this it could simply keep its pages alive on builds that support it.

Notes for review

  • <keep-alive> matches by component name, so a name has to be unique among pages. Documented in PLUGINS.md.
  • A kept-alive page keeps running its watchers and timers in the background. That is why it is opt-in, and the docs say to use onActivated / onDeactivated and to make route watchers ignore changes while the route isn't the component's own (the stock Explorer does this with isOnExplorerRoute()).
  • Not included, but possible as follow-ups: a per-record :key so several routes sharing one component (Flexible Layouts' custom pages) each get their own cached instance instead of one shared one, and a max on <keep-alive>.

Testing

  • vue-tsc --noEmit: no errors in the changed files.
  • The Vue behaviour this relies on was checked in a throwaway test (not committed): a name added to a reactive include after the host was created still caches a later mount; without the name the component remounts; a dispatcher named after the page keeps whichever child it renders, and one with the generic name does not.
  • Not exercised in a running DWC yet: an actual registerRoute({ keepAlive: true }) page, or an overridden Explorer, clicked through in a browser.

🤖 Generated with Claude Code

registerRoute() takes a new `keepAlive` option (true = the component's own name, or an explicit name), stored in the route's
meta.keepAlive like the file-based pages do. The names are tracked in a reactive registry that DwcRouterView reads, so a page
registered after the router view was created is cached too (it used to snapshot router.getRoutes() once at setup).
unregisterRoute() withdraws the request.

The route override dispatcher now takes the name of the page it wraps when that page is kept alive, so overriding the Explorer
no longer stops it being cached.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chrishamm chrishamm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The shape is right: opt-in, one meta.keepAlive field for both route kinds, and the
reactive include list. vue-tsc is clean here too. Three things before this goes in.

resolveKeepAliveName mishandles defineAsyncComponent. The wrapper's own name is
"AsyncComponentWrapper", so keepAlive: true passes the check and puts that string
in the include list, while matches an async child against
vnode.type.__asyncResolved, so nothing is cached and nothing warns. The reverse
case is rejected: keepAlive: "MyPage" on a wrapper trips the mismatch branch,
although that name is exactly what matches once the loader resolves. Treat a wrapper
like a loader function, take a given string as-is there, and have true warn that a
lazy component needs an explicit name.

The warning string needs splitting in two. "give the component a name
(defineOptions({ name })) and it will not be cached until then" does not parse, and
in the mismatch branch the component does have a name, just not the one asked for.

Dispatchers leak out of the cache. Once uninstallRouteOverridesFor restores the
original component, the cached dispatcher keeps its entry, because the cache key is
the vnode type and its name is still in the include list. It renders null from then
on and there is no max on the keep-alive, so nothing collects it. A comment saying
so may be the better answer than a fix.

Please also click through the Explorer override in a browser before this is ready.

…dispatcher residue

resolveKeepAliveName treated a defineAsyncComponent wrapper as an ordinary component. Its own name is
"AsyncComponentWrapper", so keepAlive: true put that in the include list (<keep-alive> matches the resolved
component, so nothing was cached and nothing warned) and keepAlive: "MyPage" was rejected as a mismatch. A
wrapper (recognised by __asyncLoader, as Vue does) now counts as lazy like a loader function: a string is
taken as given, true is refused with a warning that says why.

The warning is now one message per case (no name / name mismatch / lazy) instead of one that read wrongly
in the mismatch case.

The route override dispatcher keeps the cache entry it gets by sharing the page's name after the override
is dropped. Measured in Chrome: the override component itself is released (the dispatcher re-renders to
nothing when the layout goes), and what stays is one empty component per registerLayout/unregisterLayout
cycle, and only while the shell hosting the <keep-alive> outlives the unregistration. Documented at the
dispatcher rather than fixed: reusing one dispatcher per path would leave it showing a stale null on
re-install, since the override map is not reactive.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@jaysuk

jaysuk commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, all three points were right. Pushed e82bd8a.

defineAsyncComponent. Confirmed against Vue 3.5.43: the wrapper is named AsyncComponentWrapper and <keep-alive> matches __asyncResolved, so true cached nothing silently and an explicit name was rejected. resolveKeepAliveName now treats a wrapper (__asyncLoader, the check Vue itself uses) like a loader function: a string is taken as given, true warns that a lazy component needs an explicit name. PLUGINS.md says so. I ran the old and new function against the same cases; the old one fails exactly the two wrapper cases.

Warning text. Split into three messages: no name, name differs from the one asked for, and lazy component given true.

Dispatcher leak. I went with the comment, after measuring it rather than assuming. In Chrome, with a layout registered and unregistered four times on the Explorer path while the built-in shell stayed mounted, the <keep-alive> cache gained one dead dispatcher per cycle and nothing else. The override component itself is released: the dispatcher re-renders to nothing when registeredLayouts changes, its interval stopped and the component unmounted. I also tried forcing that re-render explicitly, and it changed nothing, so there is no code fix for this part. The residue also only outlives a shell that hosts the <keep-alive> past the unregistration (a custom shell takes its cache with it). Reusing one dispatcher per path would avoid the growth but leaves a reactivated dispatcher showing a stale null, since _routeOverrides isn't reactive, so it would need more machinery than the leak is worth. The comment is at the dispatcher; a max stays a possible follow-up.

Browser check. Ran against the DWC dev server in Chrome, registering through window.DWC:

  • registerRoute({ keepAlive: true }) keeps a typed input across a visit elsewhere. A page without keepAlive loses it.
  • A defineAsyncComponent route and a () => import() loader route with an explicit name are both cached. true on the async one is refused and warned, and a mismatched name is not cached and is warned.
  • A layout overriding the Explorer path renders its override in place of the Explorer, keeps it alive across a visit to another page (one mount, no unmount), and after unregisterLayout the override unmounts and its timer stops.

I did not load the stock Explorer page against a live machine, only that the override path and the built-in shell behave.

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