@@ -7,11 +7,28 @@ import {
77} from "@testing-library/react" ;
88import { userEvent } from "@testing-library/user-event" ;
99import { afterEach , describe , expect , it , vi } from "vitest" ;
10- import { MemoryRouter } from "react-router" ;
10+ import { MemoryRouter , useLocation } from "react-router" ;
11+ import type { RenderResult } from "@testing-library/react" ;
1112import { App } from "../src/app/app.tsx" ;
1213import type { Stack } from "../src/api/stacks.ts" ;
1314import type { State } from "../src/api/states.ts" ;
1415
16+ function LocationProbe ( ) {
17+ const location = useLocation ( ) ;
18+
19+ return (
20+ < div
21+ data-testid = "location-probe"
22+ data-search = { location . search }
23+ data-pathname = { location . pathname }
24+ hidden
25+ />
26+ ) ;
27+ }
28+
29+ const readLocationSearch = ( ) =>
30+ screen . getByTestId ( "location-probe" ) . getAttribute ( "data-search" ) ?? "" ;
31+
1532const healthResponse = ( ) =>
1633 new Response ( JSON . stringify ( { status : "ok" , database : "ok" } ) , {
1734 status : 200 ,
@@ -55,12 +72,22 @@ type FetchHandler = (
5572 init ?: RequestInit ,
5673) => Response | Promise < Response > ;
5774
58- const renderApp = ( initialEntry : string ) =>
59- render (
60- < MemoryRouter initialEntries = { [ initialEntry ] } >
75+ const renderApp = (
76+ initialEntry : string | string [ ] ,
77+ initialIndex ?: number ,
78+ ) : RenderResult => {
79+ const entries = Array . isArray ( initialEntry ) ? initialEntry : [ initialEntry ] ;
80+
81+ return render (
82+ < MemoryRouter
83+ initialEntries = { entries }
84+ initialIndex = { initialIndex ?? entries . length - 1 }
85+ >
86+ < LocationProbe />
6187 < App />
6288 </ MemoryRouter > ,
6389 ) ;
90+ } ;
6491
6592const mockFetch = ( handler : FetchHandler ) => {
6693 vi . stubGlobal (
@@ -590,6 +617,10 @@ describe("stack list screen", () => {
590617 renderApp ( `/?stateId=${ stackStates [ 1 ] ! . id } ` ) ;
591618
592619 await waitFor ( ( ) => {
620+ expect ( readLocationSearch ( ) ) . toBe (
621+ `?stateId=${ stackStates [ 1 ] ! . id } ` ,
622+ ) ;
623+
593624 const stackList = screen . getByRole ( "list" ) ;
594625 expect (
595626 within ( stackList ) . getByRole ( "link" , { name : / S i d e p r o j e c t / } ) ,
@@ -625,11 +656,16 @@ describe("stack list screen", () => {
625656
626657 await waitFor ( ( ) => {
627658 expect ( screen . getByRole ( "list" ) ) . toBeInTheDocument ( ) ;
659+ expect ( readLocationSearch ( ) ) . toBe ( "" ) ;
628660 } ) ;
629661
630662 await user . click ( screen . getByRole ( "radio" , { name : "Active" } ) ) ;
631663
632664 await waitFor ( ( ) => {
665+ expect ( readLocationSearch ( ) ) . toBe (
666+ `?stateId=${ stackStates [ 1 ] ! . id } ` ,
667+ ) ;
668+
633669 const stackList = screen . getByRole ( "list" ) ;
634670 expect (
635671 within ( stackList ) . getByRole ( "link" , { name : / S i d e p r o j e c t / } ) ,
@@ -642,6 +678,8 @@ describe("stack list screen", () => {
642678 await user . click ( screen . getByRole ( "radio" , { name : "All" } ) ) ;
643679
644680 await waitFor ( ( ) => {
681+ expect ( readLocationSearch ( ) ) . toBe ( "" ) ;
682+
645683 const stackList = screen . getByRole ( "list" ) ;
646684 expect (
647685 within ( stackList ) . getByRole ( "link" , { name : / S t a c k d r a f t / } ) ,
@@ -652,16 +690,94 @@ describe("stack list screen", () => {
652690 } ) ;
653691 } ) ;
654692
693+ it ( "restores filtered stacks from browser history navigation" , async ( ) => {
694+ mockFetch (
695+ defaultStacksHandler ( {
696+ stacks : [
697+ existingStack ,
698+ {
699+ ...existingStack ,
700+ id : "00000000-0000-4000-8000-000000000011" ,
701+ title : "Side project" ,
702+ description : "" ,
703+ stateId : stackStates [ 1 ] ! . id ,
704+ } ,
705+ ] ,
706+ } ) ,
707+ ) ;
708+
709+ const user = userEvent . setup ( ) ;
710+ const { unmount } = renderApp ( "/" ) ;
711+
712+ await waitFor ( ( ) => {
713+ expect ( screen . getByRole ( "list" ) ) . toBeInTheDocument ( ) ;
714+ } ) ;
715+
716+ await user . click ( screen . getByRole ( "radio" , { name : "Active" } ) ) ;
717+
718+ await waitFor ( ( ) => {
719+ expect ( readLocationSearch ( ) ) . toBe (
720+ `?stateId=${ stackStates [ 1 ] ! . id } ` ,
721+ ) ;
722+
723+ const stackList = screen . getByRole ( "list" ) ;
724+ expect (
725+ within ( stackList ) . getByRole ( "link" , { name : / S i d e p r o j e c t / } ) ,
726+ ) . toBeInTheDocument ( ) ;
727+ expect (
728+ within ( stackList ) . queryByRole ( "link" , { name : / S t a c k d r a f t / } ) ,
729+ ) . not . toBeInTheDocument ( ) ;
730+ } ) ;
731+
732+ unmount ( ) ;
733+
734+ cleanup ( ) ;
735+ renderApp ( [ "/" , `/?stateId=${ stackStates [ 1 ] ! . id } ` ] , 0 ) ;
736+
737+ await waitFor ( ( ) => {
738+ expect ( readLocationSearch ( ) ) . toBe ( "" ) ;
739+
740+ const stackList = screen . getByRole ( "list" ) ;
741+ expect (
742+ within ( stackList ) . getByRole ( "link" , { name : / S t a c k d r a f t / } ) ,
743+ ) . toBeInTheDocument ( ) ;
744+ expect (
745+ within ( stackList ) . getByRole ( "link" , { name : / S i d e p r o j e c t / } ) ,
746+ ) . toBeInTheDocument ( ) ;
747+ } ) ;
748+
749+ cleanup ( ) ;
750+ renderApp ( [ "/" , `/?stateId=${ stackStates [ 1 ] ! . id } ` ] , 1 ) ;
751+
752+ await waitFor ( ( ) => {
753+ expect ( readLocationSearch ( ) ) . toBe (
754+ `?stateId=${ stackStates [ 1 ] ! . id } ` ,
755+ ) ;
756+
757+ const stackList = screen . getByRole ( "list" ) ;
758+ expect (
759+ within ( stackList ) . getByRole ( "link" , { name : / S i d e p r o j e c t / } ) ,
760+ ) . toBeInTheDocument ( ) ;
761+ expect (
762+ within ( stackList ) . queryByRole ( "link" , { name : / S t a c k d r a f t / } ) ,
763+ ) . not . toBeInTheDocument ( ) ;
764+ } ) ;
765+ } ) ;
766+
655767 it ( "clears a stale state filter from the URL and shows all stacks" , async ( ) => {
656768 mockFetch (
657769 defaultStacksHandler ( {
658770 stacks : [ existingStack ] ,
659771 } ) ,
660772 ) ;
661773
662- renderApp ( "/?stateId=00000000-0000-4000-8000-000000009999" ) ;
774+ renderApp (
775+ "/?stateId=00000000-0000-4000-8000-000000009999" ,
776+ ) ;
663777
664778 await waitFor ( ( ) => {
779+ expect ( readLocationSearch ( ) ) . toBe ( "" ) ;
780+
665781 const stackList = screen . getByRole ( "list" ) ;
666782 expect (
667783 within ( stackList ) . getByRole ( "link" , { name : / S t a c k d r a f t / } ) ,
@@ -842,6 +958,64 @@ describe("stack detail screen", () => {
842958 } ) ;
843959 } ) ;
844960
961+ it ( "does not move focus to the heading after a successful save" , async ( ) => {
962+ mockFetch ( ( input , init ) => {
963+ const url = new URL ( String ( input ) , "http://stackdraft.local" ) ;
964+ const method = init ?. method ?? "GET" ;
965+
966+ if (
967+ url . pathname === `/api/stacks/${ existingStack . id } ` &&
968+ method === "PATCH"
969+ ) {
970+ return Promise . resolve (
971+ new Response (
972+ JSON . stringify ( {
973+ ...existingStack ,
974+ title : "Saved title" ,
975+ updatedAt : "2026-01-03T00:00:00.000Z" ,
976+ } ) ,
977+ {
978+ status : 200 ,
979+ headers : { "Content-Type" : "application/json" } ,
980+ } ,
981+ ) ,
982+ ) ;
983+ }
984+
985+ return Promise . resolve (
986+ defaultStacksHandler ( { stacks : [ existingStack ] } ) (
987+ input ,
988+ init ,
989+ ) ,
990+ ) ;
991+ } ) ;
992+
993+ const user = userEvent . setup ( ) ;
994+ renderApp ( `/stacks/${ existingStack . id } ` ) ;
995+
996+ const editForm = await screen . findByRole ( "form" , { name : "Edit Stack" } ) ;
997+ const submitButton = within ( editForm ) . getByRole ( "button" , {
998+ name : "Save changes" ,
999+ } ) ;
1000+ const heading = await screen . findByRole ( "heading" , {
1001+ name : "Stackdraft" ,
1002+ level : 1 ,
1003+ } ) ;
1004+
1005+ await user . clear ( within ( editForm ) . getByLabelText ( "Title" ) ) ;
1006+ await user . type ( within ( editForm ) . getByLabelText ( "Title" ) , "Saved title" ) ;
1007+ await user . click ( submitButton ) ;
1008+
1009+ await waitFor ( ( ) => {
1010+ expect (
1011+ screen . getByRole ( "heading" , { name : "Saved title" , level : 1 } ) ,
1012+ ) . toBeInTheDocument ( ) ;
1013+ } ) ;
1014+
1015+ expect ( heading ) . not . toHaveFocus ( ) ;
1016+ expect ( submitButton ) . toHaveFocus ( ) ;
1017+ } ) ;
1018+
8451019 it ( "sends the default state id when changing back from a non-default state" , async ( ) => {
8461020 const activeStack : Stack = {
8471021 ...existingStack ,
0 commit comments