From 68b4f7d2424f5e506f5e05433415d834e81a403b Mon Sep 17 00:00:00 2001 From: JSap0914 Date: Tue, 14 Jul 2026 20:45:16 +0900 Subject: [PATCH 1/2] fix(static): preserve matched handler 404s --- middleware/static.go | 6 ++++-- middleware/static_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/middleware/static.go b/middleware/static.go index 62cf04e34..200f96a30 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -269,10 +269,12 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) { } var he echo.HTTPStatusCoder - if !errors.As(err, &he) || !config.HTML5 || he.StatusCode() != http.StatusNotFound { + if (c.Path() != "" && c.RouteInfo().Method != echo.RouteNotFound) || + !errors.As(err, &he) || + !config.HTML5 || he.StatusCode() != http.StatusNotFound { return err } - // is case HTML5 mode is enabled + echo 404 we serve index to the client + // In HTML5 mode, serve index for a router-level 404. file, err = currentFS.Open(config.Index) if err != nil { return err diff --git a/middleware/static_test.go b/middleware/static_test.go index 3567c0a55..bf3f10081 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -44,6 +44,36 @@ func TestStatic_useCaseForApiAndSPAs(t *testing.T) { } +func TestStaticHTML5PreservesMatchedHandlerNotFound(t *testing.T) { + e := echo.New() + e.Use(StaticWithConfig(StaticConfig{ + Root: "testdata/dist/public", + HTML5: true, + })) + e.GET("/api/users/:id", func(c *echo.Context) error { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/users/42", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.JSONEq(t, `{"message":"user not found"}`, rec.Body.String()) + + group := echo.New() + group.Group("/app", StaticWithConfig(StaticConfig{ + Root: "testdata/dist/public", + HTML5: true, + })) + req = httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) + rec = httptest.NewRecorder() + group.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "

Hello from index

\n") +} + func TestStatic(t *testing.T) { var testCases = []struct { name string From f7971fa028a07cf58c7c057752501091a7217df1 Mon Sep 17 00:00:00 2001 From: JSup Date: Thu, 16 Jul 2026 03:20:09 +0900 Subject: [PATCH 2/2] test(static): use table-driven test for HTML5 404 preservation cases --- middleware/static_test.go | 79 +++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/middleware/static_test.go b/middleware/static_test.go index bf3f10081..889169eea 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -45,33 +45,64 @@ func TestStatic_useCaseForApiAndSPAs(t *testing.T) { } func TestStaticHTML5PreservesMatchedHandlerNotFound(t *testing.T) { - e := echo.New() - e.Use(StaticWithConfig(StaticConfig{ - Root: "testdata/dist/public", - HTML5: true, - })) - e.GET("/api/users/:id", func(c *echo.Context) error { - return echo.NewHTTPError(http.StatusNotFound, "user not found") - }) - - req := httptest.NewRequest(http.MethodGet, "/api/users/42", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + var testCases = []struct { + name string + buildEcho func() *echo.Echo + whenURL string + expectCode int + expectJSONEq string + expectContains string + }{ + { + name: "ok, router-matched handler 404 is preserved instead of serving index", + buildEcho: func() *echo.Echo { + e := echo.New() + e.Use(StaticWithConfig(StaticConfig{ + Root: "testdata/dist/public", + HTML5: true, + })) + e.GET("/api/users/:id", func(c *echo.Context) error { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + }) + return e + }, + whenURL: "/api/users/42", + expectCode: http.StatusNotFound, + expectJSONEq: `{"message":"user not found"}`, + }, + { + name: "ok, router-level 404 for group without matched route still serves index", + buildEcho: func() *echo.Echo { + e := echo.New() + e.Group("/app", StaticWithConfig(StaticConfig{ + Root: "testdata/dist/public", + HTML5: true, + })) + return e + }, + whenURL: "/app/dashboard", + expectCode: http.StatusOK, + expectContains: "

Hello from index

\n", + }, + } - assert.Equal(t, http.StatusNotFound, rec.Code) - assert.JSONEq(t, `{"message":"user not found"}`, rec.Body.String()) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := tc.buildEcho() - group := echo.New() - group.Group("/app", StaticWithConfig(StaticConfig{ - Root: "testdata/dist/public", - HTML5: true, - })) - req = httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) - rec = httptest.NewRecorder() - group.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "

Hello from index

\n") + assert.Equal(t, tc.expectCode, rec.Code) + if tc.expectJSONEq != "" { + assert.JSONEq(t, tc.expectJSONEq, rec.Body.String()) + } + if tc.expectContains != "" { + assert.Contains(t, rec.Body.String(), tc.expectContains) + } + }) + } } func TestStatic(t *testing.T) {