                 Changes in version 1.0.0 (2026-10-14)                  

First stable release. Routing is rewritten on top of {routr}, which
brings parameterised routes and per-method pages, and fixes a family of
bugs that came from keeping the current page in shared state.

Breaking changes

  - keys is gone. A parameterised page used to read its matched values
    from a keys object that brochureApp() installed in the global
    environment: it shadowed any user object of that name and was
    invisible from a module, a golem app or any local(). Use get_keys()
    instead — with no argument inside a page server, and
    get_keys(request) inside a page ui.

  - set_cookie() now defaults to http_only = TRUE and same_site = "Lax".
    Pass http_only = FALSE or same_site = NULL to get the previous
    header.

  - set_cookie(), remove_cookie() and server_redirect() reject values
    they used to interpolate as is: a cookie name, value, domain or path
    outside the RFC 6265 charset, and a redirect target that is neither
    a path nor an http(s) URL.

  - ... in brochureApp() no longer accepts anything. It takes page()s,
    redirect()s, tags, tag lists, html dependencies and strings;
    anything else is an error rather than content silently injected into
    every page. A bare list of pages is spliced, so
    brochureApp(list(page_1(), page_2())) now builds two pages instead
    of injecting the list into each of them.

  - page() and redirect() gain a method argument, so a page can answer
    on something other than GET.

Bug fixes

  - Two people browsing two pages of the same app could each run the
    other's server. The matched page was kept in a single per-app
    environment, written by the http handler and read back when the
    websocket opened. The page is now resolved per request in the ui,
    and per session in the server.

  - An unknown url served the last page visited instead of content_404.

  - req_handlers and res_handlers passed to page() were stored and never
    run: the documented /healthcheck endpoint and the per-page cookies
    of the README were silent no-ops.

  - A page whose href had more than one segment (any parameterised
    route) loaded none of its assets: Shiny emits its dependencies as
    relative urls, and the browser resolved them against the parent
    path. With no shiny.js there was no websocket, so the page server
    never ran.

  - basepath is now removed from the incoming path, as documented, and
    not only prepended to the urls the app emits. Apps work behind a
    proxy that passes the mount through as well as behind one that
    strips it.

  - remove_cookie() could not delete a cookie set with an explicit path
    or domain. A cookie is only replaced when name, path and domain all
    match, and the deletion header carried none of them, so the browser
    scoped it to the directory of the current request and left the
    original cookie alone. Behind a mount that directory is not /, so
    the documented login/logout pattern silently failed on Posit
    Connect. It now takes path and domain.

  - Pages and redirects are matched in the order they were passed.
    Redirects were registered after every page whatever the order, so a
    redirect() declared before a page() at the same href never answered.

  - redirect() accepts only the statuses a browser
    follows: 301, 302, 303, 307 and 308. The allow-list read
    c(301:308, 310), which admitted 304, 305, 306 and 310 — none of
    which make a browser follow a Location.

  - basepath has to be a plain url path. It is interpolated into urls
    and into the injected javascript, where a quote in it closed the
    string literal.

  - Mount rewriting is blind to capitalisation, so <IMG SRC="logo.png">
    is rewritten like its lowercase equivalent.

  - An explicit basepath now decides the mount even on Posit Connect.
    The header used to win, so an app that set basepath to pin the
    behaviour was stripping the incoming path with one mount and writing
    its urls with another.

  - server_redirect() and redirect() refuse a backslash anywhere in the
    target. A URL parser reads it as a slash, so "\page2" was the root
    and escaped the mount, which is only ever prefixed onto a /.

  - Mount rewriting handles single quoted attributes, so HTML("<img
    src='logo.png'>") is rewritten like its double quoted equivalent.

  - A page declared with a method other than GET can serve its document.
    Shiny's UI handler answers GET alone unless the ui says otherwise,
    so such a page returned nothing unless a request handler answered
    for it.

  - server_redirect() and redirect() refuse an empty target, which is
    neither a path nor a url: the browser dropped it silently, and the
    HTTP redirect emitted an empty Location.

  - set_cookie() checks max_age, the one part still interpolated into
    the header unexamined: max_age = "0\r\nX-Injected: 1" appended a
    header of its own.

  - server_redirect() refuses a protocol relative target written with
    backslashes. A URL parser reads \ as /, so "\\host" and "/\host"
    left the app exactly as "//host" did.

  - Mount rewriting only touches real attributes, and covers form
    action. It matched anywhere in the response, so page text or inline
    javascript holding src="logo.png" was rewritten too; and a form
    posting to /submit under a mount went to the domain root.

  - redirect() did not check to, which goes straight into a Location
    header: a CRLF in it appended a header of its own. It is now held to
    the same rule as server_redirect().

  - An internal redirect() sent the browser out of the app when it was
    mounted under a prefix. The redirect answers before anything is
    rewritten, so its Location was emitted unprefixed.

  - A page declared with a method other than GET never ran its server.
    The websocket handshake is a GET whatever the page answers on, so it
    matched no route; sessions are now resolved against the path alone.

  - A root absolute resource url such as src="/img.png" was left outside
    the app under a mount, while a relative navigation link such as <a
    href="contact"> was rewritten to /contact, moving where its author
    pointed it. Resource urls and navigation links are now treated
    separately.

  - Two apps in the same R process shared their app level req_handlers
    and res_handlers, and the last one built won.

  - Formulas now work as app level handlers, as the README says they do;
    only page level handlers converted them.

  - An app behind a proxy that passes its mount through -- shinyProxy,
    or an nginx proxy_pass without a trailing slash -- loaded none of
    its assets. Shiny's resource paths are served by httpuv before R and
    matched against the raw path, so /myapp/jquery-3.7.1/jquery.min.js
    matched no static path and landed on brochure, which answered
    content_404. Without shiny.js there was no websocket, so no page was
    ever interactive. Such a request now falls through to the handler
    Shiny keeps for it. Checked from a browser against inst/subpage
    behind both kinds of proxy: 41 checks pass on each, where the
    pass-through one failed on the first page.

  - /reactlog is served again. Every path brochure did not route got
    content_404, and Shiny's own handler for that endpoint comes after
    the app's in the chain, so options(shiny.reactlog = TRUE) and
    Ctrl+F3 answered 404.

  - Two pages carrying two bslib::bs_theme()s no longer fight over one
    url. Shiny builds a dependency's url prefix from its name and
    version, and both themes ship bootstrap at the same version from two
    different compiled directories: the page rendered last took the
    prefix, and the browser then styled pages with whichever css it had
    cached under it. Each source directory now gets its own prefix.

Other

  - The script brochure injects now only registers the handler behind
    server_redirect(). It also used to rewrite the page's <base>, on the
    premise that the websocket url was computed from it; shiny builds
    that url from window.location.pathname instead. Checked on a Posit
    Connect deployment: stripping the script from the response left a
    deep page with the same websocket url, the same rendered output and
    no failed request.

  - get_mount() reads the mount from the RStudio-Connect-App-Base-URL
    header only when the app really runs on Posit Connect, and keeps the
    extracted path to a plain path. Elsewhere any client could send that
    header and decide the prefix every url of the page is rewritten
    with.

  - The {routr} dependency is on the CRAN release, and {uuid} and
    {fastmap} are no longer needed.

  - golem_hook() leaves a tested app: it writes a test for the page it
    creates, along with the testthat structure to run it under R CMD
    check, and adds brochure to the Imports of the app it hooks -- the
    module template calls page(), so nothing the hook wrote could be
    loaded or tested without it.

  - The golem recommended tests come in a brochure version, and
    golem::use_recommended_tests() is taken out of dev/01_start.R. The
    ones golem writes drive app_ui() and app_server(), which the hook
    deletes: a freshly created app failed its own test suite. What
    replaces them is the app object and the page it serves on /.

  - vignette("design") says how a brochure app behaves with several
    visitors at once: a session is one visitor on one page, they all
    share one R process, and a page that blocks holds up every other
    page.

  - vignette("handlers") and ?page say why a page on a method other than
    GET is unreachable when the app has a www/ directory at its root:
    Shiny mounts it as a static path on /, and httpuv answers anything
    that is not a GET or a HEAD from there with a 400 before R sees the
    request.

  - Seven vignettes and a pkgdown site. vignette("brochure") gets you
    started, vignette("handlers") covers the request and response
    middleware, vignette("cookies") the cookie API and carrying a
    session between pages, vignette("design") what changes when an app
    is several pages, vignette("deployment") serving the app under a
    prefix, vignette("testing") testing the routing and the pages, and
    vignette("golem") building a brochure app as a golem package.
