From 1ddbbcbda36d7c0c89165ef3c746335ede129378 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 13 May 2026 20:34:59 -0400 Subject: [PATCH] fix(KeepAliveRoute): preserve children getter to avoid creating sibling routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for KeepAliveRoute: 1. Stale Show subscribed to a sibling route's outlet The component wrapper spread the route's `childProps` when invoking the user component: `props.component({ ...childProps, isAlive })`. That spread invokes the router's `get children()` getter, which constructs a `` and subscribes it to `routeStates`. KeepAlive preserves this subtree across navigations, so the Show stays alive after the user navigates away. When a later navigation populates `routeStates()[i+1]` (the keep-alive route's would-be child index) with the matched context of an unrelated sibling route, that stale Show fires and constructs the sibling's component out of the preserved subtree. Concrete repro: visit `/browse/:filter` (KeepAliveRoute), then navigate to a sibling route whose nested component sits at the same depth (e.g. `examples` -> `tmdb`). TMDB's body runs twice — once from the stale Show in the preserved Browse subtree, once from the actual Portal render after the lazy import resolves. Fix: build the inner props object via `Object.create(childProps, ...)` instead of spreading. The `children` getter stays on the prototype and is only invoked when the user component actually reads it. No Proxy, so older platforms (Tizen/WebOS) remain supported. 2. Route key drift on re-evaluation `` returns a fresh routeDef object on every call, and solid- router uses that object identity as the route key. If KeepAliveRoute ever gets re-evaluated, the new routeDef would change the key, forcing `routeStates` to dispose + recreate sibling contexts and re-invoke their components. Cache the resolved `` JSX per key so the routeDef identity is stable. Caveat documented inline: this captures `props` at first invocation; export `clearKeepAliveRouteCache` for the rare cases that need it. Co-Authored-By: Claude Opus 4.7 --- src/primitives/KeepAlive.tsx | 65 ++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/primitives/KeepAlive.tsx b/src/primitives/KeepAlive.tsx index 7cda551..ca81c2a 100644 --- a/src/primitives/KeepAlive.tsx +++ b/src/primitives/KeepAlive.tsx @@ -156,6 +156,23 @@ const KeepAliveRouteInternal = createKeepAliveComponent( storeKeepAliveRoute, ); +// Cache the resolved JSX per key. Solid Router uses the routeDef +// object itself as the route key (see @solidjs/router index.js:455), so if +// KeepAliveRoute ever gets re-evaluated, a new routeDef would drift the key +// and force routeStates to dispose + recreate sibling contexts (which would +// re-invoke their components). Returning the same JSX reference keeps the +// route key stable across re-evaluations. +// +// Note: this captures `props` at first invocation. If you rely on dynamic +// changes to KeepAliveRoute props (e.g., a reactive `transition` or `preload` +// reference), use a stable wrapper around them or clear this cache when +// they change. +const keepAliveRouteCache = new Map(); + +export const clearKeepAliveRouteCache = (): void => { + keepAliveRouteCache.clear(); +}; + export const KeepAliveRoute = ( props: RouteProps & { id?: string; @@ -173,6 +190,12 @@ export const KeepAliveRoute = ( }, ) => { const key = props.id || props.path; + + const cached = keepAliveRouteCache.get(key); + if (cached) { + return cached; + } + let savedFocusedElement: ElementNode | undefined; const getExisting = () => { @@ -246,23 +269,37 @@ export const KeepAliveRoute = ( } : undefined; - return ( + const componentWrapper = (childProps: RouteProps) => { + const existing = getExisting(); + // Do NOT spread `childProps`: it has a `children` getter (the router's + // outlet) that would be invoked eagerly, creating a subscribed to + // the next routeStates index. That stale Show would then fire when the + // user navigates to a sibling route whose matches populate that index, + // causing the sibling's component to be created from the preserved + // KeepAlive subtree. Inherit via prototype so the getter is preserved. + const innerProps = Object.create(childProps, { + isAlive: { value: existing.isAlive!, enumerable: true, configurable: true }, + }) as RouteProps & { isAlive: s.Accessor }; + return ( + + {props.component(innerProps)} + + ); + }; + + const routeElement = ( { - const existing = getExisting(); - return ( - - {props.component({ ...childProps, isAlive: existing.isAlive! })} - - ); - }} + component={componentWrapper} /> ); + + keepAliveRouteCache.set(key, routeElement); + return routeElement; };