diff --git a/src/primitives/KeepAlive.tsx b/src/primitives/KeepAlive.tsx index 2f2d4a2..71a03e7 100644 --- a/src/primitives/KeepAlive.tsx +++ b/src/primitives/KeepAlive.tsx @@ -14,6 +14,10 @@ export interface KeepAliveElement { isAlive?: s.Accessor; setIsAlive?: (v: boolean) => void; dispose?: () => void; + // Focused node captured on route exit so it can be refocused on re-entry. + // Stored here (rather than in a KeepAliveRoute closure) so it becomes + // garbage as soon as the entry is dropped from the map. + savedFocusedElement?: ElementNode; } const keepAliveElements = new Map(); @@ -199,8 +203,6 @@ export const KeepAliveRoute = ( return cached; } - let savedFocusedElement: ElementNode | undefined; - const getExisting = (): KeepAliveElement => { let existing = keepAliveRouteElements.get(key); if (!existing) { @@ -212,11 +214,20 @@ export const KeepAliveRoute = ( }; const onRemove = chainFunctions(props.onRemove, (elm: ElementNode) => { - savedFocusedElement = activeElement(); + const existing = keepAliveRouteElements.get(key); + if (existing) { + existing.savedFocusedElement = activeElement(); + } elm.alpha = 0; }); const onRender = chainFunctions(props.onRender, (elm: ElementNode) => { + const existing = keepAliveRouteElements.get(key); + const savedFocusedElement = existing?.savedFocusedElement; + if (existing) { + existing.savedFocusedElement = undefined; + } + let isChild = false; let current = savedFocusedElement; while (current) { diff --git a/tests/keepAlive.test.tsx b/tests/keepAlive.test.tsx new file mode 100644 index 0000000..6aa3244 --- /dev/null +++ b/tests/keepAlive.test.tsx @@ -0,0 +1,95 @@ +import * as v from 'vitest'; +import * as lng from '@solidtv/solid'; + +// @solidjs/router is aliased to tests/stubs/solidjs-router.ts, whose +// hands its props straight back — which is all this test needs. +import { + KeepAliveRoute, + keepAliveRouteElements, + removeKeepAliveRoute, + clearKeepAliveRoute, + clearKeepAliveRouteCache, +} from '../src/primitives/KeepAlive.jsx'; +import { renderer } from './setup.js'; + +const wait = (ms = 10) => new Promise((r) => setTimeout(r, ms)); + +// Renders the route's component wrapper and returns the KeepAlive , +// which carries the chained onRemove/onRender we want to exercise. +const renderRoute = (path: string) => { + const routeProps = KeepAliveRoute({ + path, + component: () => ( + + + + ), + }) as any; + + let outer!: lng.ElementNode; + const dispose = renderer.render(() => ( + + {routeProps.component({})} + + )); + + return { keepAliveView: outer.children[0] as lng.ElementNode, dispose }; +}; + +v.describe('KeepAliveRoute saved focus', () => { + v.afterEach(() => { + clearKeepAliveRoute(); + clearKeepAliveRouteCache(); + }); + + v.test('stores the focused element on the map entry, then releases it on re-entry', async () => { + const { keepAliveView, dispose } = renderRoute('/stores'); + await wait(); + + const focused = keepAliveView.children[0]!.children[0] as lng.ElementNode; + focused.setFocus(); + await wait(); + v.expect(lng.activeElement()).toBe(focused); + + keepAliveView.onRemove!(keepAliveView); + v.expect(keepAliveRouteElements.get('/stores')!.savedFocusedElement).toBe( + focused, + ); + + keepAliveView.onRender!(keepAliveView); + await wait(); + v.expect(lng.activeElement()).toBe(focused); + // Pointer dropped once it has been used — nothing left to retain. + v.expect( + keepAliveRouteElements.get('/stores')!.savedFocusedElement, + ).toBeUndefined(); + + dispose(); + }); + + v.test('dropping the map entry drops the saved element with it', async () => { + const { keepAliveView, dispose } = renderRoute('/dropped'); + await wait(); + + const focused = keepAliveView.children[0]!.children[0] as lng.ElementNode; + focused.setFocus(); + await wait(); + + keepAliveView.onRemove!(keepAliveView); + v.expect(keepAliveRouteElements.get('/dropped')!.savedFocusedElement).toBe( + focused, + ); + + // No closure holds the pointer, so removing the entry is enough to make + // the saved element collectable. Re-entering falls back to the route + // element instead of refocusing a node from the torn-down subtree. + removeKeepAliveRoute('/dropped'); + v.expect(keepAliveRouteElements.has('/dropped')).toBe(false); + + keepAliveView.onRender!(keepAliveView); + await wait(); + v.expect(lng.activeElement()).not.toBe(focused); + + dispose(); + }); +}); diff --git a/tests/stubs/solidjs-router.ts b/tests/stubs/solidjs-router.ts new file mode 100644 index 0000000..8303c0b --- /dev/null +++ b/tests/stubs/solidjs-router.ts @@ -0,0 +1,4 @@ +// The real @solidjs/router ships untranspiled .jsx compiled against a +// different solid moduleName, which vitest can't load. Nothing under test +// needs router behaviour — only the props KeepAliveRoute hands to . +export const Route = (props: unknown) => props; diff --git a/vitest.config.ts b/vitest.config.ts index 4d12e2d..18fe28b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,5 +24,14 @@ export default defineConfig(({ mode }) => ({ }, resolve: { conditions: ['@solidtv/source', 'browser', 'development'], + alias: { + // @solidjs/router resolves to untranspiled .jsx under the + // `@solidtv/source` condition, which vitest can't load. Tests only + // need to exist. + '@solidjs/router': new URL( + './tests/stubs/solidjs-router.ts', + import.meta.url, + ).pathname, + }, }, }));