# Upgrading to v0.20

## Bump Your Deps

Update Backpex to the latest version:

```elixir
defp deps do
  [
    {:backpex, "~> 0.20.0"}
  ]
end
```

v0.20 brings two changes that touch every application: UI state (theme, sidebar,
column and metric visibility) now goes through a unified, server-rendered
preference system instead of cookies and `localStorage`, and the `app_shell`
ships a collapsible sidebar with a new slot API.

Work through the steps below in order. After each step, verify in the browser
that the feature still works — toggle theme, columns, metrics and sidebar
sections, navigate to another resource, then reload.

## 1. Router: remove `ThemeSelectorPlug`, mount `backpex_routes()`

`Backpex.ThemeSelectorPlug` has been removed. The theme is now assigned by the
`Backpex.InitAssigns` on-mount hook.

```diff
  pipeline :browser do
    ...
-   plug Backpex.ThemeSelectorPlug
  end
```

`backpex_routes()` is now **required**: `app_shell/1` resolves the preferences
endpoint on every render and raises when the route is missing. In v0.19 the
equivalent cookie route was only needed by `theme_selector` and
`toggle_columns`, so a layout without either could render without it.

```elixir
# router.ex
scope "/" do
  pipe_through :browser

  backpex_routes()
end
```

The route was renamed from `/backpex_cookies` to `/backpex_preferences`, and
`Backpex.Router.cookie_path/1` to `Backpex.Router.preferences_path/1`. If the
routes are mounted more than once, or inside a scope with dynamic segments, pass
an explicit `preferences_path` to `app_shell`.

Because `Backpex.InitAssigns` is a LiveView hook, `@current_theme` is only
assigned on LiveView-rendered pages. Controller-rendered pages that share your
root layout (a login page, error pages) fall back to the layout's default theme.
If they should follow the stored preference, read it in your own plug — it must
run after `fetch_session`:

```elixir
def assign_current_theme(conn, _opts) do
  ctx = Backpex.Preferences.Context.from_conn(conn)
  theme = Backpex.Preferences.get(ctx, Backpex.Preferences.Keys.theme())

  Plug.Conn.assign(conn, :current_theme, if(is_binary(theme), do: theme))
end
```

## 2. `app.js`: wrap your connect params with `backpexParams`

Backpex sends the browser's stored preferences in the LiveView connect params so
`mount/3` can render the user's actual state on the first render. Without it,
preferences written after the websocket connected revert as soon as the user
navigates to another resource (Backpex logs a console warning).

```diff
- import { Hooks as BackpexHooks } from 'backpex'
+ import { Hooks as BackpexHooks, backpexParams } from 'backpex'

  const liveSocket = new LiveSocket('/live', Socket, {
-   params: { _csrf_token: csrfToken },
+   params: backpexParams({ _csrf_token: csrfToken }),
    hooks: { ...BackpexHooks }
  })
```

Keep it a function, not a plain object — LiveView re-evaluates it on every join.
If your app already computes connect params dynamically, see
[Backpex Hooks and connect params](../get_started/installation.md#backpex-hooks-and-connect-params).

If you were calling `BackpexHooks.BackpexThemeSelector.setStoredTheme()`, remove
it. The server now renders the correct theme on initial page load.

If you register hooks individually instead of spreading `BackpexHooks`, register
all of `BackpexSidebar` (new, drawer state), `BackpexSidebarSections` and
`BackpexPreferencesHook` — omitting the latter silently disables preference
persistence.

## 3. Root layout: `@theme` becomes `@current_theme`

```diff
- <html data-theme={assigns[:theme] || "light"}>
+ <html data-theme={assigns[:current_theme] || "light"}>
```

## 4. Admin layout: `app_shell`, branding, theme selector, sections

`Backpex.InitAssigns` provides four assigns for the layout:

| Assign | Type | Description |
|--------|------|-------------|
| `@current_theme` | string | The user's selected theme |
| `@sidebar_open` | boolean | Whether the sidebar is open (desktop) |
| `@sidebar_section_states` | map | Map of section IDs to open/closed state |
| `@preferences_manifest` | map \| nil | Signed adapter-route namespace manifest |

`app_shell` now declares `socket` as required. `sidebar_open` and
`preferences_manifest` have defaults, so omitting them still renders — but loses
the restored sidebar state and the correct first paint after a toggle-and-reload.
Pass them through.

The branding moved out of the topbar into the new `<:sidebar_branding>` slot and
`topbar_branding/1` was renamed to `sidebar_branding/1`. Your `<:sidebar>`
content itself does not change.

**Before:**

```heex
<Backpex.HTML.Layout.app_shell fluid={@fluid?} live_resource={@live_resource}>
  <:topbar>
    <Backpex.HTML.Layout.topbar_branding />
    <Backpex.HTML.Layout.theme_selector socket={@socket} themes={[{"Light", "light"}]} />
  </:topbar>
  <:sidebar>
    <Backpex.HTML.Layout.sidebar_section id="blog">
      <:label>Blog</:label>
    </Backpex.HTML.Layout.sidebar_section>
  </:sidebar>
</Backpex.HTML.Layout.app_shell>
```

**After:**

```heex
<Backpex.HTML.Layout.app_shell
  socket={@socket}
  fluid={@fluid?}
  live_resource={@live_resource}
  sidebar_open={@sidebar_open}
  preferences_manifest={@preferences_manifest}
>
  <:topbar>
    <div class="flex-1"></div>
    <Backpex.HTML.Layout.theme_selector current_theme={@current_theme} themes={[{"Light", "light"}]} />
  </:topbar>
  <:sidebar_branding>
    <Backpex.HTML.Layout.sidebar_branding />
  </:sidebar_branding>
  <:sidebar>
    <Backpex.HTML.Layout.sidebar_section id="blog" sidebar_section_states={@sidebar_section_states}>
      <:label>Blog</:label>
    </Backpex.HTML.Layout.sidebar_section>
  </:sidebar>
</Backpex.HTML.Layout.app_shell>
```

Note two details on `sidebar_section/1`:

- `id` is now required and must be unique. It becomes the suffix of the
  preference key `global.sidebar_section.<id>` and is used for `aria-controls`,
  so sections without unique ids would toggle together. Use only
  `[A-Za-z0-9_-]+`.
- `sidebar_section_states` must be passed explicitly. It is a function
  component, so an omitted attribute does not inherit the surrounding assign —
  it defaults to `%{}`, every section server-renders open, and the hook then
  seeds itself from that markup, so collapsed sections are lost rather than
  briefly flashed.

> #### Custom layouts: `theme_selector` is no longer self-contained {: .warning}
>
> In v0.19 the theme selector carried its own endpoint, so it worked in any
> layout. In v0.20 every preference write goes through one element that carries
> the endpoint, and `app_shell/1` renders it. **If your layout uses `app_shell`,
> there is nothing to do.** If it does not, render
> `Backpex.HTML.Layout.preferences_root/1` yourself, once per page — otherwise
> every preference write is silently dropped. The old
> `theme_selector socket={@socket}` call site still compiles, so this is not
> caught at compile time. See
> [Custom layouts must render `preferences_root`](../live_resource/user-preferences.md#custom-layouts-must-render-preferences_root).

## 5. Opt in to persisted column and metric visibility

In v0.19, column visibility and metric visibility were stored in the session
without an opt-in. In v0.20 persistence is opt-in per resource:

```elixir
use Backpex.LiveResource,
  adapter_config: [...],
  persist: [:columns, :metrics]
```

Default is `[]`. Add `persist: [:columns, :metrics]` to each resource where both
toggles should keep surviving reloads. `:order` and `:filters` can be persisted
the same way — see
[Opt-in persistence](../live_resource/user-preferences.md#opt-in-persistence-for-ordering-filters-columns-metrics).

## 6. Default ordering follows `primary_key`

When `init_order` is omitted, the index now orders ascending by the
LiveResource's configured `primary_key` instead of always by `:id`. Resources
using the default `primary_key: :id` are unchanged. If a resource sets another
primary key but intentionally sorted by `:id`, configure that explicitly with
`init_order: %{by: :id, direction: :asc}`.

## 7. Existing preference state is not migrated

The new Session adapter reads only the `"backpex_preferences"` session key. It
does not read the v0.19 `"backpex"` session tree, and the new hooks do not read
the old `localStorage` entries. Upgrading therefore resets existing UI choices.

| v0.19 state | v0.20 behavior |
|---|---|
| `session["backpex"]["theme"]`, `localStorage["backpexTheme"]` | Not read; the configured/default theme is used until the user selects one again. |
| `session["backpex"]["column_toggle"]` | Not read; columns use their configured defaults. |
| `session["backpex"]["metric_visibility"]` | Not read; metrics start visible. |
| `localStorage["sidebar-section-<id>"]` | Not read; unknown sections start open. |

Backpex ships no automatic migration because session and scope lifecycles differ
between hosts. If preserving existing choices matters, translate the old tree to
the new keys in your application before clearing it. Remove the legacy
`localStorage` entries when convenient.

## 8. The cookie form protocol has been replaced

The old endpoint accepted HTML forms such as `select_theme`, `toggle_columns`
and `toggle_metrics`, then redirected. The new endpoint accepts
`{"key": ..., "value": ...}` or `{"preferences": [...]}` and returns JSON.
Renaming an old form action while leaving its fields intact returns
`400 {"ok": false, "error": "missing key/value"}`.

**Before (no longer supported):**

```heex
<form action={Backpex.Router.cookie_path(@socket)} method="post">
  <input type="hidden" name="select_theme" value="dark" />
  <button>Dark</button>
</form>
```

**After:** write from the browser with
`BackpexPreferences.set('global.theme', 'dark', { mirror: 'session' })`, or from
a LiveView with `Backpex.Preferences.put(socket, key, value, mirror: :session)`.
See [Custom preferences](../live_resource/user-preferences.md#custom-preferences)
and the [HTTP endpoint contract](../live_resource/user-preferences.md#http-endpoint-contract).

## Behavior changes without migration steps

- **Sidebar breakpoint raised from `md` to `lg`.** Tablets between 768–1023 px
  now see the mobile drawer where they previously saw the inline sidebar.
- **Empty sidebar sections are hidden via CSS.** `sidebar_item/1` marks its root
  with `data-sidebar-item`. Add that attribute to the root element of custom
  leaf markup rendered directly inside a section, or the section counts as empty.
- **`BackpexSidebarSections` no longer writes `data-section-open`.** That
  attribute is server-owned now. Anything keying CSS or a test off the
  hook-written value must key off `aria-expanded` instead.
- **New short-lived cookie `backpex_prefs`.** It carries not-yet-acknowledged
  preference writes so a reload right after a toggle paints the new state
  (`path=/`, `SameSite=Lax`, `max-age=300`, not `HttpOnly`). It is a strictly
  functional cookie — route tokens are keyed digests, not raw user or tenant ids
  — but confirm the classification your consent policy requires. Details in
  [What Backpex stores in the browser](../live_resource/user-preferences.md#what-backpex-stores-in-the-browser).
- **Preference storage is pluggable.** With no config every key routes to the
  Phoenix session adapter, so no action is required. Route a prefix to a
  database adapter when you outgrow the ~4 KB cookie ceiling or need
  preferences to follow a user across devices or stay isolated between tenants:
  [Storage adapters](../live_resource/user-preferences.md#storage-adapters).
- **Renamed translation msgids.** Update your `.po` files:

  | Removed msgid | Replacement msgid |
  |---|---|
  | `Main desktop navigation` | `Main navigation` |
  | `Main mobile navigation` | `Main navigation` (merged) |
  | `Toggle menu` | `Toggle sidebar` |
