Skip to content

Advanced Routing - Experimental#16366

Merged
ematipico merged 121 commits intomainfrom
advanced-routing
May 6, 2026
Merged

Advanced Routing - Experimental#16366
ematipico merged 121 commits intomainfrom
advanced-routing

Conversation

@matthewp
Copy link
Copy Markdown
Contributor

@matthewp matthewp commented Apr 16, 2026

Changes

Overview of the changes:

Architecture

This change reflects an new architectural approach to request handling in Astro, with the goal of breaking up Astro features like redirects, i18n, and middleware, to be written as composable handlers for those features.

The old architecture looked something like this:

App#render -> RenderContext -> runtime render

With much of the logic for these features either within App#render or RenderContext#render.

The new architecture keeps App as a thin layer for compatibility with the adapter APIs. Adapters can still create instances of App and call App#match and App#render like previous.

The new architecture is:

App#render -> FetchHandler -> Astro features as handlers -> runtime render

Fetch handler

App#render now does very little except calling FetchHandler#fetch. This is the fetch API that users can implement with src/app.ts:

export default {
  fetch(request: Request) {
    return new Response('ok', { status: 200 });
  }
}

If the user doesn't provide a src/app.ts, or the experimental feature is not enabled, we use the DefaultFetchHandler. This handler just creates a FetchState instance and calls astro(state).

FetchState

The FetchState object is an object that holds state for the request. It contains data such as the routeData, pathname, the response, and the APIContext, among other things.

Handler functions can mutate this object to change the state.

This object replaces the RenderContext object. It's mostly the same object, but renamed to be user-facing and to add some more state that wasn't part of the RenderContext.

The object is exposed so that users can create instances of FetchState to pass into the handler functions like astro(state).

Handlers

This PR adds handlers for Astro features as described in the RFC.

The AstroHandler is the main handler at core/routing/handler.ts and it composes Actions, i18n, pages, etc. to provide the normal request handling experience of Astro. It's exposed as the astro() function on astro/fetch and astro/hono.

In general this follows a pattern of {feature}/handler.ts, so you'll see new handler.ts files for various features. Most of these take a FetchState object and either return a Response or undefined.

Some like middleware take a callback function since they need to wrap request handling in order to call middleware with the response for post-processing.

App / Pipeline / RenderOptions

Much of the internals of Astro depend on either an App or a Pipeline. In the new architecture the App is added to the Request via a symbol which is what allows the new handlers to have access to the Pipeline object.

This is not ideal and it means if user code does new Request('/'), that object will not have the App and things will break. It's uncommon for a user to want to do that, but long term we'll want to remove the dependency on App and Pipeline and instead depend directly on the Manifest, which is globally available.

API

The virtual:astro:fetchable virtual module is what loads the user's src/app.ts when the experimental flag is enabled or falls back to the DefaultFetchHandler.

Testing

  • New tests added for the various handlers, see units/fetch/ for most of the new ones.
  • Some RenderContext tests were rewritten to user FetchState since that replaces the render context object.

Docs

…handler.ts

Move the request handling logic (trailing slash redirects, route matching,
rendering, error handling, session persistence, reroutable status codes)
from BaseApp#render() into a new AstroHandler class. BaseApp#render() now
delegates to AstroHandler#handle().

This is a pure code extraction with no behavioral changes -- the same code
runs in the same order. The goal is to create a seam for future incremental
refactoring of the request pipeline.

Also changes #prepareResponse to a non-private method so both BaseApp
(renderError) and AstroHandler can call it.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 63f5566

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the pkg: astro Related to the core `astro` package (scope) label Apr 16, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 16, 2026

Merging this PR will not alter performance

✅ 18 untouched benchmarks


Comparing advanced-routing (63f5566) with main (1b1c218)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (ba2d2e3) during the generation of this report, so 1b1c218 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…roHandler

Add an intermediate FetchHandler layer so future work can compose
additional handlers without changing BaseApp. DefaultFetchHandler owns an
AstroHandler instance and exposes a standard fetch(request) signature.

- New FetchHandler type in core/fetch/types.ts
- New DefaultFetchHandler in core/fetch/default-handler.ts
- New render-options module in core/app/render-options.ts with the symbol
  and helpers for attaching ResolvedRenderOptions to a Request
- AstroHandler#handle() now takes only a request and reads options from
  the request via the symbol, keeping FetchHandler signatures clean
- BaseApp#render() attaches resolved options to the request and delegates
  to DefaultFetchHandler#fetch()

Pure refactor with no behavioral changes. All tests pass.
- Annotate DefaultFetchHandler#fetch with the FetchHandler type so
  types.ts is no longer unused (fixes knip 'unused files').
- Drop the export on renderOptionsSymbol since only same-file helpers
  use it (fixes knip 'unused exports').
Decouple renderError and prepareResponse from BaseApp so the request
pipeline no longer depends on app-level methods for these concerns.

- prepareResponse moves to core/app/prepare-response.ts as a pure helper.
  AstroHandler and the error handlers call it directly instead of going
  through BaseApp.
- Introduce an ErrorHandler interface in core/errors/handler.ts.
- DefaultErrorHandler (core/errors/default-handler.ts) holds the 404/500
  route rendering, prerendered error page fetch, and mergeResponses
  logic that previously lived on BaseApp.
- DevErrorHandler (core/errors/dev-handler.ts) is shared by the Vite dev
  server and the non-runnable dev pipeline, parameterized by a
  shouldInjectCspMetaTags flag (the only real difference between the
  two overrides).
- BuildErrorHandler (core/errors/build-handler.ts) throws on 500 and
  delegates other errors to DefaultErrorHandler with
  prerenderedErrorPageFetch cleared.
- BaseApp exposes a protected createErrorHandler() factory; subclasses
  override this instead of overriding renderError. BaseApp#renderError()
  now just forwards to the configured handler.

Pure refactor with no behavioral changes. All tests pass, lint:ci passes.
Move the trailing-slash normalization + redirect logic into its own
class so it can be composed independently of the full AstroHandler
pipeline. TrailingSlashHandler#handle(request) returns either a redirect
Response or undefined (meaning 'no redirect needed, continue').

AstroHandler now owns a TrailingSlashHandler and short-circuits on its
response. No behavioral changes; all tests and lint pass.
Introduce an AstroMiddleware class that owns the middleware orchestration
previously embedded in RenderContext.render(). AstroHandler now owns a
single AstroMiddleware instance and invokes it directly instead of going
through RenderContext.render() on the happy path.

- New AstroMiddleware class in core/middleware/astro-middleware.ts takes
  a Pipeline and composes sequence(...internalMiddleware, userMiddleware)
  at render time. Its handle(renderContext, componentInstance, slots?)
  method encapsulates the full render orchestration: build props,
  create apiContext + actionApiContext, loop-detection counter, external
  redirect short-circuit, skipMiddleware branch, callMiddleware call,
  and response finalization (strip ROUTE_TYPE_HEADER, attach cookies).
- RenderContext no longer stores a middleware field. The old 'lastNext'
  closure is now the public RenderContext.renderRoute() method, which
  mutates context state on rewrites (routeData, pathname, url, etc.)
  and dispatches to the appropriate render function by route type.
- RenderContext.render() remains as a thin entry point used by the
  error handlers and the container: it builds a fresh AstroMiddleware
  and delegates.
- AstroHandler owns #astroMiddleware = new AstroMiddleware(app.pipeline)
  and calls astroMiddleware.handle() at the three former
  renderContext.render() call sites (cache onRequest, CDN cache path,
  no-cache path).

Pure refactor with no behavioral changes. All tests pass, lint:ci passes.
…ler + BaseApp

Centralize per-request state (routeData + pathname) into a new FetchState
class. This replaces a tangle of ad-hoc locals in AstroHandler.handle(),
validates the matched route in one place, and removes the need for
getPathnameFromRequest to live on BaseApp.

- New FetchState class in core/app/fetch-state.ts holds the request,
  the matched routeData, and the resolved pathname. Its constructor
  computes the raw pathname (base strip + decodeURI with fallback);
  validateRouteData() runs the full routing flow (adapter-provided,
  dev/prod match, 404 fallback, .html normalization) and returns a
  boolean indicating success.
- Several kinds of validation previously inlined in AstroHandler.handle
  (adapter routeData logging, locals type check, routeData resolution,
  pathname computation + normalization) now live either on BaseApp.render
  (input contract checks) or on FetchState (per-request resolution).
- BaseApp.getPathnameFromRequest removed. The only external caller —
  the Cloudflare integration's dev-match fallback path — now inlines
  the same 8-line computation.
- RenderErrorOptions gains an optional pathname; the default + dev
  error handlers use it when provided and otherwise fall back to a
  short-lived FetchState for the raw pathname. Recursive retries
  (middleware-skip path) thread pathname through so it is preserved.

Pure refactor. Build, 2340 unit tests, and lint:ci all pass.
@github-actions github-actions Bot added the pkg: integration Related to any renderer integration (scope) label Apr 16, 2026
… of rendering

Introduce a single ActionHandler that handles both Astro Action modes in
one place, upstream of RenderContext.render():

- RPC (POST to /_actions/<name>): runs the action and returns the
  serialized result as the response, short-circuiting the pipeline.
  The /_actions/[...path] RouteData still exists in the manifest but
  the endpoint is no longer reached because the handler intercepts
  earlier.
- Form (POST to a page URL with ?_action=<name>): runs the action,
  stores the result in locals._actionPayload, and returns undefined
  so the pipeline continues to render the page. A subsequent call to
  getActionContext during page rendering sees the stored payload and
  skips re-running.

Wiring:
- AstroHandler owns a single ActionHandler instance and calls
  actionHandler.handle(renderContext) immediately after
  createRenderContext. An RPC response is logged, finalized via
  prepareResponse, and returned. Otherwise rendering continues.
- The form-action auto-execution block previously inlined in
  RenderContext.renderRoute is removed. The getActionContext import
  in render-context.ts is no longer needed.

Tests:
- One unit test in render-context.test.ts asserted that calling
  renderContext.render() directly auto-executes form actions. That
  coupling was exactly what we moved; the end-to-end behavior
  (POST form -> action runs -> page renders) is preserved through
  AstroHandler. The test is skipped with a TODO; we'll revisit by
  adding ActionHandler-level coverage later.

Build, 2339 unit tests (1 newly skipped), and lint:ci all pass.
… finalization run around it

The previous commit wired ActionHandler ahead of AstroMiddleware in
AstroHandler.handle, which bypassed user middleware and the response
finalization step (attachCookiesToResponse). That broke:

- actions tests where middleware intercepts action requests (custom
  errors, response fallback, RPC middleware handling).
- sessions tests that expected session cookies to be attached to RPC
  responses.

Move the ActionHandler call back into RenderContext.renderRoute (same
position the old form-action block lived) so it runs from inside the
middleware chain's 'next' callback. Middleware sees action requests
first, and the response flows through AstroMiddleware.#finalize which
strips ROUTE_TYPE_HEADER and attaches cookies. The unified
single-handler-for-both-modes design is preserved; only the placement
changed.

- ActionHandler.handle now takes APIContext (from renderRoute's ctx).
- AstroHandler.handle no longer references ActionHandler.
- The form-action unit test in render-context.test.ts is unskipped
  (end-to-end behavior through renderContext.render is preserved).
… AstroMiddleware

Introduce a new PagesHandler class that owns dispatch of the matched
route (endpoint / redirect / page / fallback), in-flight rewrites, and
the ActionHandler invocation. Previously this logic lived inline in
RenderContext.renderRoute and was then duplicated onto AstroHandler as
part of the callback-passing refactor. Both callers now delegate to a
single PagesHandler implementation.

- core/pages/handler.ts: new PagesHandler(pipeline) class. Its handle()
  method contains the full body of the old renderRoute: rewrite
  handling, ActionHandler dispatch, the switch over routeData.type, and
  the cookie merge-back. Holds its own #actionHandler and the pipeline.
- AstroMiddleware.handle() accepts an optional RenderRouteCallback. When
  provided (the AstroHandler happy path), it's used as the 'next'
  callback at the bottom of the middleware chain. When omitted (error
  handlers, container), it falls back to renderContext.renderRoute.
- RenderContext.render() accepts and forwards the optional callback to
  AstroMiddleware. RenderContext owns a #pagesHandler and
  renderRoute() is a thin delegate.
- AstroHandler owns a single #pagesHandler and passes its handle method
  as the callback to AstroMiddleware. AstroHandler no longer has its
  own renderRoute copy.
- RenderContext.url and RenderContext.createNormalizedUrl are now
  public (previously protected / private static) so PagesHandler can
  update url on rewrites.

Pure refactor. Build, 2340 unit tests, 26 actions integration tests,
and lint:ci all pass.
…rs instead of RenderContext.render

Three related cleanups that continue decoupling rendering logic from
RenderContext:

1. Error handlers (DefaultErrorHandler, DevErrorHandler) now own their
   own AstroMiddleware + PagesHandler instances, built from the app's
   pipeline in the constructor. They call
   astroMiddleware.handle(renderContext, mod, {}, pagesHandler.handle)
   directly instead of going through renderContext.render(mod). This
   removes them as consumers of RenderContext's internal fallback path.

2. The container API (experimental_AstroContainer) does the same: holds
   its own AstroMiddleware + PagesHandler and dispatches through them
   instead of renderContext.render(componentInstance, slots).

3. User-triggered rewrites (Astro.rewrite / ctx.rewrite) now recurse
   through AstroHandler instead of RenderContext.#executeRewrite.
   AstroHandler.handle is split into handle(request) (trailing slash,
   FetchState, routeData validation) and render(state) (the inner
   pipeline). A new #rewriteAndRender(state, payload) mutates the
   FetchState in place and calls render(state) again, producing a
   fresh RenderContext while carrying locals / renderOptions /
   timeStart / request / routeData / pathname across the rewrite.

   FetchState gains a mutable request, a renderOptions field (resolved
   from the render-options symbol up front), and a timeStart field.
   RenderContext exposes a rewriteOverride hook that
   createAPIContext and the AstroGlobal check before falling back to
   the legacy #executeRewrite (still used by error handlers and the
   container).

   RenderContext.create now only calls setOriginPathname when the
   request doesn't already have one recorded, so the origin pathname
   preserved by #rewriteAndRender survives the new RenderContext.

All tests green: full build, 2340 unit, 26 actions, 12 sessions,
10 container, 13 rewrite, and lint:ci pass.
… rewrite

With rewrites now producing a fresh RenderContext per call, cookies
set before and during a rewrite were being lost because each
RenderContext owns its own AstroCookies bag and
AstroMiddleware.#finalize overwrites the response's cookies symbol
instead of merging. In the old flow, inner and outer renders shared a
single RenderContext (and thus a single cookie bag), so overwriting
was benign.

Thread cookies across the rewrite boundary in both directions:

- FetchState.pendingCookies carries cookies that were set on the
  outer render forward into the inner render (merged into the new
  RenderContext's cookies before its middleware runs).
- After the inner render returns, read cookies off the response and
  merge them into the outer RenderContext's cookies so that when the
  outer AstroMiddleware.#finalize attaches cookies to the response, it
  includes what the inner render set.

Fixes: 'should preserve cookies set in sequence' in middleware.test.js
(middleware-sequence-rewrite fixture).
…ck required

RenderContext is no longer responsible for rendering. All rendering now
flows through AstroMiddleware + PagesHandler explicitly.

Production:
- Extract Rewrites class to core/rewrites/handler.ts holding the body
  that was previously RenderContext.#executeRewrite. Rewrites.execute
  now drives the recursive render by constructing its own
  AstroMiddleware + PagesHandler instead of calling back into
  RenderContext.render.
- AstroMiddleware.handle's renderRouteCallback is now required; the
  fallback to RenderContext.renderRoute is gone.
- RenderContext.render and RenderContext.renderRoute are removed.
  RenderContext no longer imports AstroMiddleware or PagesHandler.
- The cookies field on RenderContext is now public so Rewrites can
  rebind it after a rewrite.

Tests:
- New renderThroughMiddleware(renderContext, componentInstance, slots?)
  helper in test/units/test-utils.ts that wires up the middleware +
  pages handler stack and returns the response.
- Updated all 7 unit test call sites (head-injection-app,
  render-context, head, csp/rendering) and the createMockPrerenderer
  helper in build/test-helpers.ts to use the new helper instead of
  RenderContext.render.

Container:
- Minor cleanup: slots default now propagates correctly.

All tests green: build, 2340 unit, 13 rewrite, 15 middleware, 26
actions, 12 sessions, 10 container, and lint:ci pass.
…class

i18n post-processing (routing strategy dispatch + fallback rewrite/redirect)
used to run as the first entry of pipeline.internalMiddleware. It was
always pure post-processing — call next(), then inspect the response and
maybe redirect / 404 / rewrite. Lift it out of the middleware layer and
run it as an explicit step in AstroHandler.render, matching the handler
extraction pattern we've been applying to other concerns.

- New core/i18n/handler.ts: I18n class constructed with
  (i18n, base, trailingSlash, format). Builds its own I18nRouter. Exposes
  finalize(request, response, ctx) containing the body of the old
  createI18nMiddleware closure. ctx carries the pieces the post-processor
  needs from an APIContext — redirect, rewrite, currentLocale,
  isPrerendered — so the class does not depend on the middleware layer.

- AstroHandler owns an optional #i18n instance (constructed when
  app.manifest.i18n is set and strategy is not 'manual'). render(state)
  runs a local runPipeline() that invokes astroMiddleware.handle and then
  #i18n.finalize if configured, before the cache-provider wrapping applies
  cache headers. The rewrite callback threads back through
  #rewriteAndRender so i18n-triggered fallback rewrites use the same
  machinery as Astro.rewrite.

- base-pipeline.ts no longer pushes createI18nMiddleware into
  internalMiddleware. The comment explains the move.

- i18n/middleware.ts is now a thin shim: the public
  astro:i18n.middleware(...) API (used for the manual routing strategy)
  instantiates I18n and wraps it in a MiddlewareHandler closure
  (await next(), then handler.finalize). Single source of truth for
  both paths.

All tests green: build, 2340 unit, 339 i18n unit, 15 middleware, 13
rewrite, 2 i18n-css-leak, 7 page-format, 7 serializeManifest, and
lint:ci pass.
… before middleware

Redirect dispatch was split across two places: AstroMiddleware
short-circuited external redirects, while PagesHandler's type switch
rendered internal redirect routes inside the middleware chain. Both
cases now go through a single Redirects handler, invoked before
middleware runs.

- New core/redirects/handler.ts: Redirects class with
  handle(renderContext) that returns Response | undefined. The
  routeIsRedirect() predicate lives inside the class so callers invoke
  handle() unconditionally and fall through on undefined, matching the
  shape of TrailingSlashHandler.handle. Delegates to the existing
  renderRedirect helper for the actual response-building logic.

- AstroHandler owns a #redirects instance and calls it immediately
  after createRenderContext. A truthy result short-circuits the
  pipeline: no middleware, no page dispatch, no i18n post-processing.
  logThisRequest + prepareResponse still run so the response is
  finalized correctly.

- AstroMiddleware.handle no longer short-circuits external redirects
  (the external-redirect branch plus imports for isRouteExternalRedirect
  and renderRedirect are gone). Redirects are caught upstream now.

- routing/match.ts: removed the isRouteExternalRedirect export and its
  redirectIsExternal import — no longer used anywhere.

Behavioral note: internal redirect routes used to go through user
middleware (user middleware could intercept and modify them). They now
short-circuit like external redirects do. Both kinds of redirect
behave consistently, and user middleware is no longer invoked for
redirect routes. PagesHandler keeps its case 'redirect' branch for
middleware-triggered rewrites that land on a redirect.

All tests green: build, 2340 unit, 26 redirect-unit, 4 redirect
integration, 15 middleware, 13 rewrite, 26 actions, and lint:ci pass.
…stroHandler.#rewriteAndRender

Previously user-triggered rewrites (Astro.rewrite / ctx.rewrite) went
through two different code paths: AstroHandler.#rewriteAndRender for
requests handled by AstroHandler (wired via RenderContext.rewriteOverride),
and Rewrites.execute for everything else (error handlers, container).
The two paths diverged in subtle ways — notably loop detection was broken
on the AstroHandler path because each recursive rewrite built a fresh
RenderContext with counter=0.

Consolidate on Rewrites.execute for all call sites.

- RenderContext: drop the rewriteOverride field and the two
  if (this.rewriteOverride) branches in createAPIContext and the
  AstroGlobal builder. Both rewrite closures now call
  this.#rewrites.execute(this, payload) directly. Add a public
  renderContext.rewrite(payload) method that wraps #rewrites.execute for
  callers (specifically i18n.finalize's fallback rewrite callback).

- AstroHandler: delete #rewriteAndRender (~70 lines) and the
  renderContext.rewriteOverride = ... installation. i18n's fallback
  rewrite callback now calls renderContext.rewrite(path). Drop the
  pendingCookies merge block in render(state) and all the imports that
  only #rewriteAndRender used (copyRequest, setOriginPathname,
  AstroError, ForbiddenRewrite, getCookiesFromResponse, RenderContext,
  RewritePayload).

- FetchState: drop the pendingCookies field and the AstroCookies
  type-only import that fed it. No longer needed — Rewrites.execute
  mutates the existing RenderContext in place so cookies don't have to
  be threaded across a rewrite boundary.

Behavioral improvement: loop detection now works for user-triggered
rewrites. renderContext.counter accumulates across rewrites on the same
RenderContext, so the counter === 4 -> 508 guard fires as documented.

All tests green: build, 2340 unit, 339 i18n unit, 13 rewrite, 15
middleware, 26 actions, 12 sessions, 4 redirects, and lint:ci pass.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

e18e dependency analysis

No dependency warnings found.

matthewp and others added 2 commits April 17, 2026 13:25
Redirects.handle was async and awaited on every request by
AstroHandler.render, even when the route was not a redirect. That
introduced a microtask hop between createRenderContext and the rest of
the render pipeline, which showed up as a ~22% slowdown on the
streaming .md file benchmark (and an 11% slowdown on the hybrid build
benchmark) on CodSpeed.

Fix: make Redirects.handle synchronous for the non-redirect case. It
now returns Promise<Response> | undefined — the Promise from
renderRedirect when the route is a redirect, or plain undefined
synchronously otherwise. AstroHandler.render checks the return value
and only awaits when it's a Promise, so non-redirect routes no longer
pay for an extra microtask.

All tests green: build, 2344 unit, 4 redirect integration, and
lint:ci pass.
Removes the Redirects class in core/redirects/handler.ts. Per-request
the hot path previously did a method call on a class field which in
turn did a helper call (routeIsRedirect) just to compare
routeData.type. Inline the comparison so non-redirect requests cost
one property access + string compare.

- AstroHandler no longer holds a #redirects: Redirects field.
- render() checks routeData.type === 'redirect' directly and calls
  renderRedirect() from core/redirects/render.js only when needed.

All tests green: build, 2344 unit, 4 redirects, 13 rewrite, 15
middleware, lint:ci.
matthewp and others added 4 commits April 20, 2026 09:01
….handle(state)

Move FetchState construction up to DefaultFetchHandler and expand it to
own the RenderContext, componentInstance, slots, props, and lazily-built
API / action API contexts for a request. Handlers now read per-request
data off a single FetchState instead of threading it through signatures.

- FetchState now takes a Pipeline (not BaseApp) so the container can
  build one too. Route matching / dev pathname normalization moves up
  to AstroHandler.
- AstroMiddleware.handle(state, renderRouteCallback) — no more passing
  renderContext, componentInstance, slots, props, and action context as
  separate args.
- PagesHandler.handle(state, ctx, payload) — reads everything off state;
  rewrites mutate state.componentInstance and invalidate memoized
  props / contexts.
- RenderContext gets a fetchState back-ref so Rewrites.execute can
  re-enter middleware with the same per-request state when invoked via
  Astro.rewrite / ctx.rewrite.
- Error handlers and the container build their own FetchState before
  calling AstroMiddleware.handle.
- getAPIContext() is synchronous; callers must await getProps() first
  (AstroMiddleware does this once on the hot path).
…enderRedirect/I18n.finalize take state

AstroHandler.render no longer calls app.createRenderContext or touches
RenderContext directly. FetchState owns creation (zero-arg lazy
getRenderContext()) and the downstream handlers that previously took a
RenderContext now take the FetchState.

- FetchState.status field + getRenderContext() that lazily calls
  RenderContext.create using state (request, pathname, routeData,
  locals, clientAddress, status). Callers that assign state.renderContext
  directly (error handlers, container) still get the memoized value.
- renderRedirect(state) — reads renderContext off state.
- I18n.finalize(state, response) — reads renderContext.computeCurrentLocale,
  routeData.prerender, and rewrite off state. The I18nFinalizeContext
  adapter interface is gone.
- fetchStateSymbol stashes the active FetchState on APIContext so the
  manual-strategy i18n middleware wrapper can retrieve it from user-
  middleware land.
- Test updates: test-utils.renderThroughMiddleware builds a FetchState
  for AstroMiddleware.handle(state); createMockAPIContext stashes a
  minimal FetchState with a duck-typed renderContext so internal shims
  keep working; new createMockFetchState helper for redirect unit tests.
FetchState now resolves routes via pipeline.matchRoute() instead of
looking up the app via appSymbol on the request. This removes the
BaseApp dependency from FetchState and allows the container API to
benefit from the same route matching.

BaseApp.match() delegates to pipeline.matchRoute() for the low-level
pattern match, keeping its own asset/prerender filtering logic.
When the adapter doesn't provide routeData, resolve it via
this.match(request) which handles computePathnameFromDomain.
pipeline.matchRoute() in FetchState doesn't know about domain
routing, so the route must be resolved at the app level.
…decode

- #stripHtmlExtension now runs in all modes, not just development
  (fixes dynamic-route-build-file test)
- #resolveRouteData filters prerendered routes in production SSR
  (fixes ssr-prerender and node adapter prerender 404 tests)
- Remove double decodeURI in #resolveRouteData since pathname is
  already decoded by #computePathname (fixes double-encoded path test)
- BaseApp.render() only resolves domain-based i18n routes, not the
  full match() which filters prerenders
@matthewp matthewp added this to the 6.3 milestone Apr 30, 2026
Comment thread packages/astro/test/units/fetch/index.test.ts Outdated
Comment thread packages/astro/test/units/fetch/index.test.ts Outdated
Comment thread packages/astro/test/units/fetch/index.test.ts
Comment thread packages/astro/test/units/fetch/index.test.ts
Comment thread packages/astro/test/units/fetch/index.test.ts
Comment thread packages/astro/test/units/fetch/index.test.ts
Comment thread packages/astro/test/units/fetch/index.test.ts Outdated
Comment thread packages/astro/test/units/fetch/index.test.ts
Copy link
Copy Markdown
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

Let's ship it :)

There's a test failing we should fix

Copy link
Copy Markdown
Member

@ArmandPhilippot ArmandPhilippot left a comment

Choose a reason for hiding this comment

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

Great work to both of you!

I haven't noticed anything that could affect the /experimental-flags/advanced-routing page. But, we'll need a section for cookies.consume() in https://docs.astro.build/en/reference/api-reference/#cookie-utilities

Docs LGTM!

matthewp and others added 2 commits May 5, 2026 08:26
The fallback sentinel (X-Astro-Route-Type: fallback, status 500) signals
that the render pipeline couldn't find a page in the current locale.
computeFallbackRoute only triggers on 404, so the 500 was silently ignored.
Map the sentinel to 404 before passing to computeFallbackRoute.
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

This PR is blocked because it contains a minor changeset. A reviewer will merge this at the next release if approved.

@ematipico ematipico merged commit d69f858 into main May 6, 2026
31 of 37 checks passed
@ematipico ematipico deleted the advanced-routing branch May 6, 2026 10:50
@astrobot-houston astrobot-houston mentioned this pull request May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs pr pkg: astro Related to the core `astro` package (scope) pkg: example Related to an example package (scope) pkg: integration Related to any renderer integration (scope) semver: minor Change triggers a `minor` release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants