Frappe UIFrappe UI

Frappe UI v1 Changelog

User-facing v1 changes. Unreleased entries describe changes since v0.1.278. Log only breaking changes, deprecations, observable behavior changes, and migration guidance — not internal refactors or test additions.

All deprecations preserve backwards compatibility through v1.x and emit a one-time dev-mode warning (unless noted). Removal is post-v1.

Unreleased

Data fetching (v2) — stale responses no longer write the shared stores (fix)

Two concurrent writes to one document could leave docStore, listStore (and every view bound to them) on the response that settled last instead of the newest one, while data already held the fresh response (#1017).

The gate lives in the stores. Every request takes a dispatch version; the response's store writes carry it, and the stores reject a write for a document that a later-dispatched request has already written. One freshness domain covers every writer — the docs side channel, the useDoctype / useList / useDoc hooks, and any mix of instances or paths writing the same document. A version is recorded only when a mutating write lands: a newer request that failed wrote nothing on the server, so it does not make the older success stale, and a read (GET) is admitted on its version but records nothing — the server may answer a later reload before an earlier save commits, and the save must still land. A delete records a fresh version when it settles — a delete is terminal — so no in-flight write or reload, whatever its dispatch order, can re-create a deleted document. One accepted limitation: the gate orders by dispatch time, so a read dispatched after a save, handled by the server before the save committed and answered after it, is admitted and republishes the pre-save value — resolving that needs server-side sequencing, which this design trades away.

useAction still skips the onSuccess/onError hooks of a submit that a newer same-key submit of the same instance already outran — the store gate protects the stores, this skip only avoids re-running hook side effects with a stale response. useDoc's write members and useNewDoc skip their hooks the same way, but their store writes stay outside that skip: only the store gate decides them. It compares per document and knows whether the newer request landed, so an overtaken insert of a different document still lands, and an older success is kept when the newer submit failed.

No API change. Behavior changes if you relied on it:

  • A stale setValue/delete on the same document no longer triggers useList's auto-refetch; the newest submit's refetch already ran.
  • Submits with different keys, and keyless submits (inserts), are independent — all of their hooks still fire, as before.

Charts — a new family at frappe-ui/charts

A second chart family ships alongside the one at the package root. It is additive: the root chart exports keep working and nothing here removes them. Import the components from frappe-ui/charts, which carries the --chart-* color tokens with it. spec/charts.md states the conventions, and spec/adr/0015-what-enters-charts.md records what the family admits.

Landed so far:

  • The chrome is exported — ChartCard, ChartContainer, ChartLegend and ChartTooltip — so a plot an app draws itself wears the family's look. ChartCard owns the card surface, and card: false turns it off.
  • Combo series through seriesConfig[key].type, which also collapsed the three axis option builders into one. Per-series area fill falls out of it.
  • Reference lines on the axis charts and on ScatterChart. A line beyond the data range is clipped rather than stretching the value axis.
  • stacked: 'normalized' for shares, and maxSeries to cap a long grouping column. maxSeries has no default.
  • ScatterChart and SankeyChart.
  • Category labels fit themselves — the library measures, tilts and truncates instead of taking an angle prop.
  • xAxis.type: 'value' reads the x column as a quantity: a point sits at its own number instead of in its row's slot, and the rows draw in numeric order. Ask for it — a column of numbers still reads as categories by default.
  • The three states are slots — #loading, #error and #empty — on ChartContainer and on every chart component, so an app puts a retry button beside a failed query without drawing chrome of its own. #loading replaces the whole placeholder rather than a caption under a spinner.
  • A loading chart draws a skeleton the size of its plot, where it used to draw a spinner and the words "Loading chart…". A dashboard fills in a card at a time, and a placeholder that holds the grid's shape reads better than eight spinners turning out of step. #loading takes it back.
  • seriesConfig[key].axis puts a series on the second value axis. It replaces the y2 prop, which is removed: y names every series once, in the order they are drawn and colored, so a series no longer changes color when it changes axis. Long data reaches the second axis for the first time, keyed by a value of the series column, and y2Axis is unchanged. To migrate, move each y2 column into y at the position it should draw at and add axis: 'y2' to that column's seriesConfig entry. TypeScript reports the removed prop, but a plain template passes it through as an attribute and draws the column not at all — grep for y2 on the v2 charts after upgrading.
  • ScatterChart takes showDataLabels, which prints each point's label beside it. Names that collide with a neighbour are dropped.
  • NumberCard takes color, the ink the reading is printed in, for a card standing for a series drawn in that color elsewhere. The card, the title and the delta tone are unchanged by it.

Changed since 1.0.0-beta.41, the first beta that shipped the family:

  • Breaking, silent: the six mark emits are renamed to selectdatapointClick on AreaChart, BarChart and LineChart, sliceClick on DonutChart, stageClick on FunnelChart, cellClick on HeatmapChart, linkClick on SankeyChart and pointClick on ScatterChart. P1 names an emit after the behavior, and the six now fire from the keyboard as well, so "click" was false. A listener on an old name stops firing with no error — see the migration guide. The payload types are unchanged.
  • Breaking, loud: ChartTheme is now ChartTokens and useChartTheme is now useChartTokens, which returns { tokens } rather than { theme }. theme means a color tone everywhere else in the library (P4), and these are the resolved --chart-* values.
  • Breaking, loud: formatValue, formatDate, formatLabel, formatPercent, formatAxisValue, currentColorScheme and resolveChartTheme are no longer exported. They had no documented use, and each format helper hardcodes en-US. Read the plot-area colors with useChartTokens, which re-resolves on a theme flip; currentColorScheme was the root resolvedColorScheme under another name.

Calendar family — moved to frappe-ui/experimental (breaking)

Calendar is not taken to bar at root for 1.0.0 (#1020, redirect of #989). It parks on frappe-ui/experimental (P14 — no stability promise) with its public API unchanged, until a redesigned calendar family replaces it.

  • Breaking, loud: import { Calendar, ... } from 'frappe-ui' fails to resolve. Import from frappe-ui/experimental instead: Calendar, CalendarColorMap, CalendarActiveEvent, and the types CalendarActions, CalendarCellClickData, CalendarConfig, CalendarEvent, CalendarMode, CalendarPublicProps, CalendarTimeFormat, GroupedCalendarEvents. Migration is the import-path change only. Apps that spread content from frappe-ui/tailwind keep Calendar styles automatically.
  • Fix: the default header's month-title button renders again (it broke when DatePicker's #target slot became #trigger), and the all-day collapse buttons show their chevron icons again.

Radius aliases and text-*-black styles removed (breaking, silent)

Per ADR-0006 and ADR-0008 (#998, decided in #993):

  • The named radius aliases (rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, and their directional forms) are removed. Numbered tokens are the only radius vocabulary (roundedrounded-4, sm→1, md→5, lg→6, xl→7, 2xl→8; identical px). rounded-none and rounded-full stay. Silent break: the preset replaces Tailwind's scale, so an unmigrated alias emits no CSS — square corners, no build error. The tokens-v2 codemod now performs these renames (idempotent, runs in every mode); it rewrites bare rounded only inside quoted strings and @apply rules, so grep for leftovers.
  • The text-<size>-black / text-p-<size>-black style classes are removed (zero usage; the Figma black weights were corrupt export data). Also a silent break. The codemod flags font-extrabold / font-black next to a text size instead of merging onto the removed class.

ListFilter — removed (breaking)

  • Breaking, loud: ListFilter is no longer exported — the import fails. Its internals (SearchComplete, FilterIcon) are gone with it (#992, #999). No consumer app used it. Build filter UI in app code with Select and Combobox.

Data fetching (v2) — useDoc writes and useNewDoc get one request per submit (fix)

useDoc's setValue, delete and every methods: entry, and useNewDoc, still held a single shared request after the useDoctype/useList fix. Two submits at once aborted one another, and every submit resolved from the same data, so a caller could receive another caller's answer or null. Each submit now sends its own request and resolves with its own response (#991).

  • No API change. These members keep the full useCall surface — same members, same types. submit() still resolves null on a failed request.
  • data and error belong to the submit that started last, same as useDoctype and useList: a stale submit answers its own caller and writes nothing shared. loading stays true until every submit settles.
  • Behavior change if you relied on it: a second submit no longer cancels the first — both requests reach the server.

Sprite icon trio — moved to frappe-ui/experimental (breaking)

The sprite-based Icon, IconPicker, and spritePlugin leave frappe-ui/icons (#904). Apps still use them, so they park on frappe-ui/experimental (P14 — no stability promise) instead of being deleted. lucide-* classes are the canonical way to render icons. The named SFC icons (CircleCheckIcon, HelpIcon, ...) stay on frappe-ui/icons.

  • Breaking, loud: import { Icon, IconPicker, spritePlugin } from 'frappe-ui/icons' fails to resolve. Import from frappe-ui/experimental instead. Migration is the import-path change only. Apps that spread content from frappe-ui/tailwind keep IconPicker styles automatically — no Tailwind change needed.
  • frappe-ui/experimental exports Icon (sprite); root frappe-ui exports a different Icon. Alias one if you import both: import { Icon as SpriteIcon } from 'frappe-ui/experimental'.

createListResourcehasPreviousPage stale after reload() (fix)

reload() temporarily resets start to 0 to re-fetch the accumulated pages as one request, then restores it. hasPreviousPage was computed while start was still 0 and never recomputed after the restore, so it stayed false even when start was back above 0. reload() now recomputes hasPreviousPage after restoring start.

CommandPalette — show renamed to open (breaking, silent)

showopen, matching the rest of the library's overlay vocabulary (CONTEXT.md). Silent break: Vue accepts the unknown show prop with no error, so the palette just never opens — grep for CommandPalette and v-model:show after upgrading. Mod+K now opens the palette on its own (registered internally via useShortcut); delete any app-level keydown listener that toggled it. CommandPalette and CommandPaletteItem also gained types.ts, docs, stories, and a Cypress test; item icons now accept string | Component (lucide class strings, emoji, or a component) instead of components only.

KeyboardShortcut — deprecated shortcut and unused modifier props removed (breaking)

Per ADR-0008, the deprecated shortcut prop (superseded by combo) is removed rather than shipped frozen — it had zero call sites. The meta / ctrl / shift / alt boolean props are also removed (Rule 9: zero real usage, superseded by combo). Both are loud breaks (removed props on a typed component). Also moved from a bare KeyboardShortcut.vue into its own directory with a types.ts, a docs page, and a Cypress test.

useShortcut — matchesShortcut no longer public (breaking)

matchesShortcut is removed from the frappe-ui package export. Its own doc comment already said "exported for unit tests only" — it was never meant to be public API (Rule 9). Loud break (import error) for anyone who imported it directly; no signal of any real consumer doing so.

KeyboardShortcutsModal / useShortcut — brought to bar

KeyboardShortcutsModal gained a types.ts and a Cypress test (previously only unit-tested). useShortcut gained a short entry on the composables page; its API was already stable and is unchanged.

SettingsDialog — SettingsBody's exposed type

SettingsBody's viewportElement expose now has a typed SettingsBodyExposed type (ADR-0012), exported from frappe-ui. No behavior change.

FileUploader — uploads default to private (security fix, breaking)

  • Silent break: isPrivateUpload() — the single resolver used by useFileUpload and FileUploadHandler — now defaults an upload with no stated private / is_private to private, not public. Previously FileUploader patched this default at the component level while useFileUpload().upload(file, {}) and new FileUploadHandler().upload(file, {}) resolved the same missing intent to public. The two disagreeing was #922; FileUploader itself already uploaded private (since v1.0.0-beta.21) and is unaffected. A caller of useFileUpload or FileUploadHandler with no explicit privacy option now gets a private file where it previously got a public one. See the migration guide.
  • Fixed: the standalone upload(file, options) export (re-exported from frappe-ui alongside useFileUpload) crashed at runtime — it required internal state/reset arguments the public signature never exposed a way to pass. It's now a real standalone function; useFileUpload() wraps it with reactive state.

FileUploader — flat props replace the uploadArgs blob (breaking, P3)

  • Silent break: uploadArgs is removed. Its fields that are actually used in the wild are now flat props: private, folder, doctype, docname, fieldname, uploadEndpoint, optimize. Old code keeps compiling — uploadArgs becomes an inert attribute on the root element — so an app that relied on it (folder, doctype, custom private, …) silently stops applying those options. Advanced options with no flat prop (file_url, method, type, params, maxWidth/maxHeight, upload cancellation) had zero measured use on the component (rule 9); reach for useFileUpload() directly for those. See the migration guide.

FileUploader — success / failure emits declared stable

  • Both emits lost their @deprecated tag (ADR-0008 forbids shipping @deprecated members at 1.0.0) and gained real types: success: [data: UploadedFile], failure: [error: unknown] (previously untyped any). Both are load-bearing at real call sites and keep their names — they already read as behaviors (P1), not interactions.
  • Fixed: failure didn't fire when validateFile rejected a file — only on an actual upload error. error was set on the slot props either way, but a listener on @failure never heard about a validation rejection. It now emits failure with the validation error (string or Error) in both cases, matching what the type already promised.

FileUploader — slot prop error is now always a string

  • Silent break: FileUploaderSlotProps.error changed from unknown to string | null. Upload failures were already normalized to a message string; validation failures (validateFile returning an Error) were not — a custom slot could receive either a string or an Error object. Both paths now normalize to a message string before reaching the slot. A slot that did {{ error.message }} expecting the validation-Error case (uncommon, but not impossible) silently renders nothing now that error is always a string. See the migration guide.

FileUploader — inputRef removed, nothing in its place

  • Breaking, loud: per ADR-0012, FileUploader hands back nothing through a template ref. inputRef was a function disguised as a ref (uploader.value.inputRef().focus()), and the openFileSelector slot prop already covers what it was used for. Zero known call sites.

FileUploader — structural bar: TypeScript, types.ts, tests, docs

  • FileUploader is now fully typed (FileUploaderProps, FileUploaderEmits, FileUploaderSlotProps in types.ts), has *.cy.ts component tests covering the five at-bar behaviors, and a data-slot="root" / data-state="idle" | "uploading" | "success" | "error" pair for CSS hooks (P10). The default fallback trigger now renders validation/upload errors via <ErrorMessage> (role="alert") — previously invisible unless the caller used a custom slot.

fileToBase64 and the fileSize helpers — unexported from root

  • Breaking, loud: fileToBase64, formatBytes, getMaxFileSize, and fileSizeLimitMessage are no longer exported from frappe-ui. Zero external call sites at the v1 sweep (rule 9) — all four stay as internal helpers shared by useFileUpload, FileUploadHandler, and the editor's media upload engine.

Per ADR-0008, every member marked @deprecated is deleted. Sidebar is now a bare composable frame: SidebarHeader / SidebarSection / SidebarLabel / SidebarItem compose in the default slot, matching the direction agreed in v1-release/plan.md.

  • Breaking, silent: Sidebar's header and sections config-object props are gone. Old code still compiles — Vue drops them as inert attrs — but the sidebar renders empty instead of the configured header/sections. Compose SidebarHeader and SidebarLabel + SidebarItem (or SidebarSection) directly in the default slot.
  • Breaking, silent: Sidebar's #header-logo and #footer-items slots are gone (they only existed to reach into the config-object layout). Old <template #header-logo> / #footer-items> content stops rendering. Put that markup directly in the default slot instead.
  • Breaking, silent: SidebarSection's items prop and #sidebar-item scoped slot are gone. It's now a plain collapsible-group wrapper — label, collapsible, v-model:collapsed — whose children are SidebarItems composed directly in its default slot, instead of an items array plus a slot to customize each row.
  • Breaking, silent: SidebarItemProps.isActive (alias for active) and .condition (config-object visibility filter) are gone. Use active; use v-if on the composed SidebarItem instead of condition.
  • Breaking, silent: SidebarHeader's #logo slot is renamed to #prefix (P6 — no type-specific slot names when a generic one covers them). Old <template #logo> content stops rendering; the default logo/initial box shows instead.
  • SidebarItem's collapsed-rail icon no longer swaps to a centered square and back while the sidebar's width animates — it holds one position through the transition (also fixes the icon sitting 2px off the rail's center line).
  • SidebarSection's collapsible label is now a real <button> with aria-expanded / aria-controls, keyboard-operable (was a <div> with a click handler and no keyboard path).

ListView family — moved to frappe-ui/experimental (breaking)

ListView is not taken to bar at root for 1.0.0. frappe-ui/list is the recommended primitive for new code, but it's a narrower, composition-based family by design — it has no equivalent for ListView's config-driven columns (resizable widths, per-column getLabel/prefix functions, cell tooltips, disabled-row exclusion, the built-in select banner). Rather than freeze the whole 12-export barrel at root undeprecated, it moves to frappe-ui/experimental (P14 — no stability promise) and stays there until frappe-ui/list reaches full functional parity.

  • Breaking, loud: import { ListView, ... } from 'frappe-ui' fails to resolve. Import from frappe-ui/experimental instead: List, ListView, ListEmptyState, ListFooter, ListGroupHeader, ListGroupRows, ListGroups, ListHeader, ListHeaderItem, ListRow, ListRowItem, ListRows, ListSelectBanner.

TextEditor and its v0 exports — removed from root (breaking)

Per ADR-0008, the deprecated v0 editor exports are removed from top-level frappe-ui — loud breaks, the import fails to resolve:

  • TextEditor, TextEditorBubbleMenu, TextEditorFixedMenu, TextEditorFloatingMenu, TextEditorContent, createEditorButton
  • ImageExtension, SetImageOptions, createSuggestionExtension, BaseSuggestionItem, CreateSuggestionExtensionOptions (the two TextEditor/extensions/* barrels also re-exported from root)

Use Editor and its kits/building blocks from the frappe-ui/editor subpath instead — see the migration guide's Editor section. This confirms CONTEXT.md's rule: the editor family is the only subsystem that exports from a subpath rather than root, and nothing editor-related is exported from root anymore.

The underlying v0 component files still ship, unmodified, as frappe-ui/editor's migration safety net. They are parked in frappe-ui/experimental (experimental/TextEditor/, #1007), so apps mid-migration keep an import path:

ts
import { TextEditor } from 'frappe-ui/experimental'

This path is unstable — no deprecation window. Sharing the experimental barrel costs its other importers nothing in production: #870's rollup measurement shows unused re-export chains are pruned before sideEffects marking applies, so the editor graph is tree-shaken out of non-editor imports. Removing the files is a separate, human-gated cleanup once every consumer has migrated (spec/editor.md §12); the TextEditor public API redesign itself is out of scope for 1.0.0 and carved out to 1.1.

Editor and TextEditor styles — Tailwind v4 theme() call fixed

.ProseMirror ul[data-type='taskList'] input[type='checkbox'] used a Tailwind-v3-only theme('colors.gray.900') call in both frappe-ui/editor's and the v0 TextEditor's stylesheet, which broke Tailwind v4 builds (#861 — a remaining instance of #299). Replaced with the same var(--ink-gray-9) token the rest of both files already use.

v1 resources — at-bar exception documented; listResource gets test coverage

v1 resources (createResource, createListResource, createDocumentResource, getCachedResource, getCachedListResource, getCachedDocumentResource, resourcesPlugin, saveLocal, getLocal, deleteLocal, onDocUpdate) ship un-deprecated and frozen at 1.0.0, per #886. ADR-0013 records the one exception: the implementation stays hand-written JavaScript rather than TypeScript, permanently — 344 production call sites make a rewrite riskier than the freeze. createListResource, the second-most-used export at 57 call sites, gets test coverage for the first time (listResource.test.ts): pagination, insert/setValue refreshing the list, caching, and reload()'s pagination-state restore.

Tailwind preset — content export added

frappe-ui/tailwind exports content, the glob list of frappe-ui source directories that emit Tailwind classes. Spread it into your app's tailwind.config.js content array instead of hand-maintaining the paths — see the new Tailwind Setup docs page. Tailwind v3 doesn't merge a preset's content, so this was previously unavoidable hand-maintenance, and it had already drifted: some apps on [email protected] glob src/components/** only, silently dropping every class the editor and list molecules emit.

Tailwind preset — tokens.js export removed (breaking)

The ./tailwind/tokens.js export is removed outright, with no deprecation window. It had zero importers anywhere and re-exported colorPalette.js via export *, the implementation-module re-export pattern disallowed by P15. Use the preset (frappe-ui/tailwind) directly.

This ships before the 1.0.0 tag, while the library "evolves freely" (P13) — the freeze that requires a deprecation window starts at the tag, not before it. Zero call sites is also why it's a same-release removal rather than a carried-forward deprecation: there is no consumer for a warning to reach.

Tailwind preset — unused token vocabulary and utilities removed (breaking)

Design-token audit before the additive-only freeze (#940): every family in tailwind/generated/*.json and every utility/--* variable plugin.js emits was checked against frappe-ui's own source, docs, and stories, plus a fresh census of all consumer apps (crm, helpdesk, gameplan, insights, builder, suite, central, frappe_calendar, frappe-ui-starter, and frappe's ui/ package). The primitive and semantic color ramps (all twelve hues, in surface-*/ink-*/outline-*), and every typography weight (including bold/black) and size through text-12xl, turned out to be real, in-use vocabulary — none of that is touched. What had zero call sites everywhere is removed:

  • text-tiny and its uppercase text-transform. Not even shown in the docs' own type-scale page.
  • text-13xl through text-16xl (and their -medium/-semibold/ -bold/-black variants). The docs' own "display sizes" showcase stops at text-12xl — these four sizes were past what even the type-scale demo used.
  • shadow-status and its backing --elevation-status variable. Named once in prose on the elevation docs page but never rendered there or anywhere else.
  • surface-alert-button-* / ink-alert-button-* (default, info, success, warning, error). Alert's buttons color via the shared variant+theme axes (P4); this Figma spec never got wired to code.
  • surface-alpha-gray-2-overlay. Resolved to the black/white overlay ramp rather than the gray-alpha ramp its name implies, breaking the {family}-{step} pattern every other surface-alpha entry follows.

All five are silent breaks — a missing Tailwind class or --* var just stops applying, no build or type error. See the migration guide.

The token generator (tailwind/figma-tokens-to-theme.js) now filters these out at the source, so they stay gone on the next yarn sync-tokens run rather than reappearing. ALPHA_FAMILIES also dropped a dead 'red-alpha' entry that never matched anything in the Figma export — no emitted token changed.

Also removed: tailwind/colors.js, a 642-line legacy color module superseded by colors.json + colorPalette.js. It had zero importers and wasn't reachable through any frappe-ui package export (no ./tailwind/* wildcard) — deleting it doesn't change anything for consumers.

frappe-ui/vite — types and docs

frappe-ui/vite now ships hand-written types (vite/index.d.ts, wired via the types export condition), so frappeui(...) and its options (frontendRoute, lucideIcons, barrelImports, frappeProxy, jinjaBootData, buildConfig, frappeTypes) are typed without a // @ts-expect-error workaround. Also added a docs page covering every sub-plugin, including barrelImports — previously undocumented on the docs site.

list-style.css and editor-style.css exports — removed

  • Breaking: the manual frappe-ui/list-style.css and frappe-ui/editor-style.css exports are gone (loud — the consumer build fails with Missing "./list-style.css" specifier). They existed only because bundlers tree-shook the side-effect import './style.css' inside the frappe-ui/list and frappe-ui/editor barrels. The barrels are now listed in sideEffects, so each family's CSS ships automatically the moment you import anything from its subpath — delete the manual @import lines. The tree-shake was never Rolldown-specific: plain Rollup/Vite production builds dropped the CSS too.

Toggles and ranged inputs — deprecated members removed

Per ADR-0008, the family's deprecated aliases are removed, not shipped frozen. All five had zero call sites across the consumer apps. These are silent breaks — old code compiles and runs, but the name is ignored (see the migration guide's Inputs table):

  • Rating.rating_frommax; Rating.readonlydisabled.
  • Switch.change emit → v-model / @update:modelValue; Switch.labelClassesdata-* styling hooks.
  • Checkbox.paddingpadded.

Slider now exports its types (SliderProps, SliderEmits, SliderValue), and Rating exports RatingEmits.

CircularProgressBar — removed

  • Breaking: CircularProgressBar is no longer exported (loud — the import fails). It was a second component for Progress's concept (P8), with hardcoded light-mode colors and a structured theme object prop (P3/P4). One call site existed across all consumer apps. Use Progress, or copy the old SFC into your app if you need the radial form.

Node.js requirement

  • Breaking: Node floor is now >=20.19.0 (was Node 18 on 0.1.x). Declared via package.json engines so installers and CI surface the requirement instead of opaque transitive-dep engine errors.

Portal target for embedded apps

  • portalTo on Popover, HoverCard, Dropdown, Select, Combobox and MultiSelect no longer declares a 'body' prop default. An unembedded app still gets 'body', now as a fallback. No existing call behaves differently.
  • New usePortalTarget / providePortalTarget / portalTargetKey exports let an embedding host redirect every overlay at once. See spec/portal-target.md.

Dialog — v1 spec

  • Flat top-level props (title, message, icon, size, position, paddingTop, actions) are canonical. The legacy options blob is removed — see below.
  • v-model:open is canonical; v-model (modelValue) still works silently.
  • New props: dismissible (default true, replaces disableOutsideClickToClose), bare, showCloseButton (default true, independent of the auto-header).
  • Canonical slots #default, #title, #actions (scoped with { close, actions }). The legacy #body* slots are removed — see below.
  • icon.theme (yellow | blue | red | green) replaces icon.appearance, which is removed — see below.
  • Auto-header no longer renders an "Untitled" fallback.

Dialog — imperative dialog.* API

  • New callback-based helpers: dialog.confirm(), dialog.danger(), dialog.prompt(). The onConfirm callback runs on click; resolving auto-closes the dialog, while throwing keeps it open and renders the thrown message inline. The action button shows a loading state until onConfirm settles. Each helper also returns a synchronous handle with close() for programmatic dismissal.
  • <FrappeUIProvider> now renders <Dialogs /> next to <Toasts />, so apps wrapped with the provider get the imperative stack for free. <Dialogs /> is still exported for callers that don't use the provider.
  • ConfirmDialog and confirmDialog() are removed — see below; use dialog.confirm() / dialog.danger().
  • New root exports: DangerArgs, DialogControl, PromptControl, DialogHandle, PromptFieldValidator. DialogSlotProps is exported from the Dialog barrel.

Dialog — deprecated surface removed (breaking)

Every member marked @deprecated is deleted, per ADR-0008. Nothing is aliased and nothing warns.

  • Breaking, silent: the options blob prop and DialogOptions type are gone. It bundled title/size/icon/actions into one object. An :options="{...}" call site still compiles — Vue drops the unknown prop as an inert attr — but the dialog silently loses its title, size and actions. Use the flat top-level props.
  • Breaking, silent: disableOutsideClickToClose is gone. It still lands as an inert attr, and dismissible (the inverse) defaults to true, so the dialog silently becomes dismissible. Use dismissible.
  • Breaking, silent: icon.appearance and DialogIconAppearance are gone; only icon.theme remains. An appearance key is dropped, so the icon renders with no tone. Map warning → yellow, info → blue, danger → red, success → green.
  • Breaking, silent: the legacy #body, #body-content, #body-main, #body-title and #body-header slots are gone. Vue drops an unknown named slot with no error, so a missed call site renders nothing where that slot's content used to be. Use #default, #title and #actions; #body maps to bare + #default.
  • Breaking: the callable-context shim on action onClick is gone. The context used to be callable as well as a plain object (ctx() closed the dialog); it is { close } only now, so calling it as a function throws TypeError: ctx is not a function.
  • Breaking: defineExpose({ close }) and the DialogExposed type are gone — Dialog exposes nothing on its template ref (ADR-0012). A template-ref .close() call throws a TypeError. Use v-model:open = false, or the close slot prop. Zero known call sites.
  • Breaking: ConfirmDialog and confirmDialog() are deleted. The import fails, so the build names every call site. Use dialog.confirm() / dialog.danger().

Before/after for the silent breaks is in the migration guide.

DatePicker family — v1 spec

DatePicker, DateRangePicker, and DateTimePicker share the v1 popover-trigger vocabulary used by Combobox / Dropdown / Select.

  • side (default 'bottom') + align (default 'start') + offset (default 4) replace placement (removed).
  • keepOpen (default false) replaces autoClose (removed, inverse).
  • typeable (default true) replaces picker-level readonly and allowCustom (both removed). :typeable="false" blocks typing while keeping the popover interactive.
  • Constraints: min?: string and max?: string (YYYY-MM-DD, plus YYYY-MM-DD HH:mm:ss on DateTimePicker), and isDateUnavailable?: (date: Dayjs) => boolean for arbitrary disabling. Min/max and the predicate compose. On DateTimePicker, minDateTime/maxDateTime are removed in favor of min/max.
  • v-model:open supported on all three pickers via open + update:open.
  • openOnFocus (default false) and openOnClick (default true) let consumers opt out of either trigger path. Same defaults applied to Combobox for parity.
  • #trigger is the canonical custom-trigger slot; #target is removed.
  • DateRangePicker.clearable now defaults to true; footer hides when there is nothing to clear. Live hover preview while picking the end date and a stable trigger width derived from format were added in the same pass.
  • Public type exports added: DateTimePickerProps, DateRangePickerEmits, DateTimePickerEmits, DateRangeValue.

DatePicker family — DateRangePicker emit shape (breaking)

DateRangePicker emits update:modelValue / change as a [from, to] tuple (DateRangeValue = [string, string] | []) instead of a comma-joined string. The modelValue prop already accepted string[]; the emit is what changes.

ts
// before
function onChange(v: string) { const [from, to] = v.split(',') }
// after
function onChange(v: DateRangeValue) { const [from, to] = v } // [] when cleared

Reactive forms that pass the value through unchanged are unaffected.

The dedicated popover footer on DatePicker, DateRangePicker, and DateTimePicker has been removed, including the auto-rendered Clear button that used to render there when clearable && hasValue. clearable still governs the input-level clear affordance.

  • New #actions slot renders as a left sidebar inside the popover. Slot props include close, setDate / setRange, and clear. DateRangePicker's setRange([from, to]) commits both endpoints atomically — use it for fixed-window presets ("Last 7 days").
  • Popover content is w-fit when the slot is provided.
  • data-slot="actions" is set on the sidebar <aside> for CSS hooks.

Migration: callers who relied on the auto-rendered Clear button should render an explicit Clear row inside #actions using the clear slot prop.

DateTimePicker — date selection keeps popover open (breaking)

Selecting a date in DateTimePicker no longer auto-closes the popover. Focus moves into the embedded TimePicker instead, so users get a continuous date → time flow. The popover closes on Esc, click-outside, or programmatic close().

Migration: callers relying on the implicit close should bind v-model:open and close from @update:modelValue, or render an "Apply" button in #actions (which receives close in its slot scope).

TimePicker — v1 refresh

Same vocabulary as the DatePicker family plus a flexible parser.

  • side / align / offset replace placement (removed).
  • keepOpen (default false) replaces autoClose (removed).
  • typeable (default true) replaces picker-level readonly / allowCustom (both removed).
  • v-model:open via open + update:open; new openOnFocus (default false) and openOnClick (default true) props.
  • Flexible typed input: "3pm", "3.30pm", "1500", "9:30:15 am" parse to canonical HH:mm[:ss].
  • min / max replace minTime / maxTime (removed).
  • scrollMode is removed; list is always centered on the selection.
  • Template ref exposes only focus() (ADR-0012). selectAll() and blurInput(), dead members with no callers, are removed.

DatePicker family — keyboard navigation

Full keyboard nav inside the calendar grid (WAI-ARIA APG Date Picker Dialog spec).

  • on the trigger input opens the popover and moves focus to the selected/today cell.
  • Grid: / ±1 day, / ±1 week, Home/End week edges, PageUp/PageDown ±1 month, Shift+PageUp/Shift+PageDown ±1 year.
  • Enter / Space selects. Esc closes and returns focus to the input.
  • Disabled dates (via min / max / isDateUnavailable) are skipped.
  • Arrow keys auto-advance across month boundaries.
  • DateRangePicker dual-pane: arrow keys cross panes without advancing the view; range-hover shading tracks the keyboard-focused cell.

Roving tabindex: exactly one cell is in the tab order, so Tab enters and leaves the grid as a single unit. Custom #trigger slots opt in automatically — any open path moves focus into the grid since a non-TextInput trigger has no typing context.

DatePicker family — legacy composable removed

useDatePicker and its helpers (getDate, getDatesAfter, getDaysInMonth, isLeapYear) were not used by any picker component and were not part of the v1 API. Deleted outright — the import fails, so the break is loud.

DatePicker / TimePicker family — deprecated aliases removed (ADR-0008)

The back-compat aliases these components carried through the betas are deleted, not kept as warn-and-map shims — per ADR-0008, no deprecated member ships in 1.0.0. Before/afters in the migration guide.

  • placement, autoClose, allowCustom, picker-level readonly, inputClass, value prop removed. All silent: a leftover prop lands as an inert extra attribute instead of doing anything.
  • #target slot removed. Content in a leftover <template #target> silently stops rendering. Use #trigger.
  • DateTimePicker.minDateTime/maxDateTime and TimePicker.minTime/maxTime removed. Silent: the constraint just stops being enforced. Use min/max.
  • TimePicker.scrollMode removed. Silent; the list is always centered.

change stays as a supported second emit alongside update:modelValue — it was never deprecated on TimePicker, and DateTimePicker depends on it internally, so removing it from the other two pickers would have been an inconsistent, unforced break.

Input family — shared labeling contract

TextInput, Textarea, Password, Checkbox, Switch, Rating, and Slider accept label, description, error, required. Id is auto-generated; <label for>, aria-describedby, aria-errormessage, aria-invalid, aria-required are wired automatically. error accepts string or Error (with Error.messages rendered as stacked plain text). Existing call sites unchanged.

Input family — data-* styling hooks

Every input shell renders the canonical data-* vocabulary so external CSS can target inputs without class-injection props:

  • data-slot ("label", "control", "description", "error")
  • data-size, data-variant (where applicable)
  • data-state ("valid" | "invalid" | "checked" | "unchecked" | …)
  • data-disabled, data-required

Password — v-model fix

Password now uses defineModel<string>(), fixing the existing bug where <Password v-model> did not update from typing. Explicit size, variant, disabled, placeholder, id, required props replace $attrs routing.

Password — value prop removed (breaking)

Per ADR-0008, no deprecated member ships in 1.0.0. value warned and seeded v-model since it was deprecated earlier in this cycle; a census of every downstream app found zero call sites still passing it. Use v-model / modelValue.

TextInput / Textarea / Password / Duration — focus() and inputElement on the ref

Implements ADR-0012.

  • Breaking: TextInput.el and Textarea.el are renamed to inputElement — a computed, typed HTMLInputElement | null / HTMLTextAreaElement | null, never a raw ref.
  • All three, plus Duration, now expose focus(options?: FocusOptions). Password previously exposed nothing.
  • TextInput, Textarea, and Password share one exported type, TextInputExposed, from TextInput's types.ts.
  • DurationExposed.focus gained the same options? parameter; its member set is unchanged.

Rating — max replaces rating_from

Default 5. (The old name was kept as a deprecated alias during the betas and is now removed — see "Toggles and ranged inputs" above.) Rating no longer imports FeatherIcon; default star comes from lucide-star via the shared Tailwind plugin. Filled stars now render visibly for non-zero values.

Slider — additive props and a11y fix

  • disabled prop added.
  • size: 'sm' | 'md' added; 'md' scales track and thumb proportionally.
  • New value-commit emit fires when dragging ends (use for side-effects you don't want on every step).
  • Removed hardcoded aria-label="Volume". Labeling now flows through the shared contract; pass label explicitly. (Treated as a bug fix — every non-volume call site was announced as "Volume" by assistive tech.)
  • Visibility: visible track in collapsed wrappers, full-width by default.
  • Uncontrolled Slider initializes to min instead of rendering with no thumb.

Switch — Lucide icons; deprecations

No longer imports FeatherIcon. icon is now string | Component; lucide-* strings route through the shared Tailwind plugin. labelClasses and the change emit were deprecated during the betas and are now removed (see "Toggles and ranged inputs" above). Row hover/active background removed.

Checkbox — padding deprecated

In favor of padded. (Now removed — see "Toggles and ranged inputs" above.)

Textarea — ghost variant; required prop

Textarea now accepts the 'ghost' variant (matching TextInput and Password) and the shared required prop.

TextInput / Textarea — ghost variant paints transparent (fix)

ghost set no bg-* class, so @tailwindcss/forms preflight painted the input #fff — a white pill in dark mode. ghost now sets bg-transparent, matching Combobox's own ghost search input. Closes #851.

FeatherIcon — removed (breaking)

Per ADR-0008, the deprecated FeatherIcon component is deleted, along with the feather-icons dependency. lucide-* strings (or a Component) are the only supported icon forms now.

  • Breaking, loud: import { FeatherIcon } from 'frappe-ui' and <FeatherIcon> fail at the import.
  • Breaking, silent: every icon-name prop across the library (Button.icon / iconLeft / iconRight, Dialog.icon, Dropdown / ContextMenu item icon, TabButtons options icon / iconLeft / iconRight, Icon.name) used to fall back to FeatherIcon for a bare feather-style name (e.g. "edit"). That fallback is gone: an unrecognized string now renders nothing, with a dev-mode console warning once per (component, prop). Prefix the name with lucide-.
vue
<!-- before -->
<FeatherIcon name="plus" class="size-4" />
<Button icon="plus" />

<!-- after -->
<span class="lucide-plus size-4" aria-hidden="true" />
<Button icon="lucide-plus" />

Hardcoded internal FeatherIcon usages across core components were migrated to lucide-* in this release.

Before/after for the silent break is in the migration guide.

Input — removed (breaking)

  • Breaking: Input and its Input.cy.ts tests are deleted. Per ADR-0008, no deprecated member ships in 1.0.0; a census of downstream apps found no live call sites left that render <Input> (five registrations were dead global component registrations, never rendered). Use TextInput for text-like modes, or Textarea / Select / Checkbox for the other type modes Input accepted.

Card, ListItem, standalone Toast — removed (breaking)

Per ADR-0008, three unmaintained wrappers that shipped @deprecated in code are deleted, not carried forward. All three had zero call sites across the census of downstream apps.

  • Breaking: Card and its .vue file are removed. No drop-in replacement — rebuild the title/subtitle/actions/loading layout with plain markup, using LoadingText or Skeleton for the loading state.
  • Breaking: ListItem and its .vue file are removed. No drop-in replacement — rebuild the title/subtitle/actions row with plain markup.
  • Breaking: the standalone Toast SFC (import { Toast } from 'frappe-ui') is removed. This only affects direct usage of the raw ToastRoot-based component; the imperative API (toast() / toast.success() / toast.error() / toast.info()) and <ToastProvider> are unaffected and unchanged.

All three fail loudly at the import. Before/after examples are in the migration guide.

FormLabel — moved to a component directory (non-breaking)

FormLabel now lives at src/components/FormLabel/FormLabel.vue instead of a bare src/components/FormLabel.vue, matching the rest of the input family. It gains types.ts, tests, stories, and a docs page. The import path for consumers (import { FormLabel } from 'frappe-ui') is unchanged.

LoadingIndicator / LoadingText — moved to component directories (non-breaking)

Same move as FormLabel, for the same reason: both now live at src/components/LoadingIndicator/ and src/components/LoadingText/ instead of bare .vue files directly under src/components/. Each gains types.ts (LoadingIndicatorProps, LoadingTextProps), stories, a docs page, and cypress tests. The import path for consumers (import { LoadingIndicator, LoadingText } from 'frappe-ui') is unchanged.

Kept as distinct components from Spinner and Skeleton (P8) — usage data across the consumer census shows real, separate demand: LoadingIndicator (~60 files) and LoadingText (~11 files) are both load-bearing, not redundant overlap.

Icon — docs page and stories added

Icon had no stories/ folder, so it did not appear in the docs site despite being a public export. It now has a docs page and two stories (lucide string form, and the Component escape hatch).

MonthPicker — removed (breaking)

MonthPicker and its whole barrel (MonthPicker.vue, types, stories) are deleted. It duplicated Select for a narrower case. Use Select with month options — see the migration guide. The import fails, so the break is loud.

Legacy components — dev-mode warnings

Pill is no longer exported from the package entrypoint. It remains an internal TabButtons detail.

ThemeSwitcher remains exported for v1 compatibility, but is deprecated. For new theme switchers, compose Select with the useColorScheme composable.

Autocomplete — removed (breaking)

  • Breaking: Autocomplete and its AutocompleteProps type are deleted. Use Combobox for one value and MultiSelect for several. The import fails, so the build names every call site. trigger="button" on either replacement is the shape Autocomplete's default target had: a button showing the selection, with the search box inside the popover.
  • Breaking, silent: FormControl type="autocomplete" is removed. The dispatcher falls through to TextInput and still forwards the type, so the result is <input type="autocomplete"> — a plain text box, with no build or runtime error. A dev-only console.error names the removal.
  • Breaking, silent: the v-model payload inverts. Autocomplete modelled the whole option object; both replacements model the value only. Listen to @update:selectedOption where the whole option is needed.
  • Breaking, silent: #target's open slot prop was the function that opened the popover; #trigger's open is the open state. Anything reading it as a value (v-if="open") was always truthy and now is not.
  • #target#trigger otherwise: Combobox and MultiSelect attach the open toggle to the trigger element themselves, so drop the click handler. A togglePopover() carried through the rename throws on click — the popover still opens, so it reads as working while logging an error.
  • Grouped options use { group, options }, not { group, items }. Both normalizers now throw naming the group and the rename, rather than dying inside a map call.

Before/after for each silent break is in the migration guide.

Three surfaces that shipped as deprecated aliases in the betas are deleted, not aliased. All three are silent breaks in plain-JS apps — before/afters in the migration guide; TypeScript callers get compile errors (the removed keys stay typed as never), and a dev-mode console warning fires when the old shape reaches the menu at runtime.

  • placement prop and DropdownPlacement type removed. Use align (leftstart, centercenter, rightend). A leftover placement is ignored and the menu falls back to align="start".
  • { group, items } removed. Use { group, options }, matching Combobox / MultiSelect / Select. A leftover items group renders as an empty menu.
  • component: option rows removed (DropdownComponentOption, ContextMenuComponentOption). Use slots: { item: fn }. A leftover component: row renders as a plain action row off its label.

Also removed: the DropdownExposed type — it described a close() template-ref member that Dropdown never implemented ([ADR-0012] keeps Dropdown's template-ref surface empty; v-model:open and the close slot prop cover it). Type-only, so the break is loud.

The trigger now forwards its disabled state (from button.disabled or a disabled fallthrough attribute) to the underlying menu primitive. Previously only the generated Button was natively disabled; a custom trigger slot with a disabled attribute could still open the menu via keyboard or synthetic clicks.

Select — #item-* slot prop renamed to item

#item-prefix, #item-label, and #item-suffix on Select expose item as the canonical scoped binding, matching Combobox and MultiSelect. The previous option key is removed with the rest of the deprecated surface (ADR-0008) — destructuring { option } yields undefined, silently. No runtime warning is possible (slot-prop destructuring isn't detectable), so grep for #item- slots destructuring option.

vue
<!-- before -->
<Select :options="people">
  <template #item-prefix="{ option }">
    <Avatar :image="option.image" />
  </template>
</Select>

<!-- after -->
<Select :options="people">
  <template #item-prefix="{ item }">
    <Avatar :image="item.image" />
  </template>
</Select>

Combobox — trigger sizing matches Select

Root renders as a transparent layout box so the trigger sizes like Select in flex/grid containers. Query decoupled from model in button mode.

Combobox / MultiSelect — #suffix slot replaces the chevron

New #suffix slot on Combobox (input and button modes) and MultiSelect, mirroring the existing slot on Select. Providing the slot replaces the default chevron — render an explicit chevron fallback when your content is conditional. Canonical use is an inline clear button. See Combobox/stories/Clearable.vue.

Combobox — condition authoritative for type: 'custom' rows

A custom row's condition({ query }) is now consulted even before the user types since opening, so it can fully gate its own visibility based on selection state and the typed query. Selectable rows are unchanged. This makes "create new" patterns expressible directly via condition, with no need for a dedicated createOption prop. See Combobox/stories/CreateNew.vue.

MultiSelect — #summary suppresses the phantom sizer

The trigger's default behavior pins a minimum width derived from the worst-case default summary (placeholder vs "N selected") so the trigger doesn't jitter as the count changes. That sizer can't predict custom text, so it's now skipped when #summary is provided — the trigger becomes content-sized and the consumer owns the width.

InputLabel — slot polish

The default required indicator is not rendered when #label is used (slot receives { required }). The labeling wrapper is dropped entirely when there is nothing to label.

Popover — v0 API removed (breaking)

Every member marked @deprecated is deleted, per ADR-0008. Nothing is aliased and nothing warns.

  • Breaking, silent: the #target, #body and #body-main slots are gone. Vue drops an unknown slot without an error, so a missed call site renders a popover with no trigger, or an empty one. Use #trigger and #default.
  • Breaking, silent: #trigger wires the click itself through reka's PopoverTrigger. A click handler carried over from #target toggles the popover a second time, so it opens and shuts on one click.
  • Breaking, silent: the togglePopover and updatePosition slot props are gone. toggle replaces the first; reka repositions on its own, so the second has no replacement.
  • Breaking, silent: placement, show, hideOnBlur, matchTargetWidth, trigger, hoverDelay, leaveDelay, popoverClass and transition are removed, and the update:show emit no longer fires. An unknown prop is ignored, so the popover renders in its default position and state.
  • Breaking, silent: attributes on <Popover> are no longer inherited. They used to land on a wrapper the legacy #target rendered; #trigger is as-child and renders no wrapper. Move class and style onto the element inside #trigger.
  • Breaking: the PopoverPlacement and PopoverLegacySlotProps types are removed. Use PopoverSide + PopoverAlign and PopoverSlotProps. The import fails, so the build names every call site.
  • Fixed: CalendarWeekDayEvent passed placement="center" in month view, which is not a side and reached reka as one. It is side="bottom" + align="center" now.
  • Breaking, silent: the slot props are { open, close, toggle }. open is now the boolean state, matching Dropdown, Select, MultiSelect, HoverCard and Sidebar; the open() method it used to be had no callers, since #trigger opens itself. isOpen is gone — read open instead. A destructured isOpen becomes undefined with no error, so styling that depends on it stops applying silently.
  • Fixed: MonthPicker styled its panel through popoverClass, which had already become a no-op, so the panel rendered with no surface at all. It uses the standard panel shell now.
  • Fixed: :dismissible="false" still closed on Escape. Only the outside-click channel was wired, while CONTEXT.md defines dismissible as covering both.

Before/after for each silent break is in the migration guide.

NestedPopover — removed (breaking)

  • Breaking: NestedPopover is deleted. Use Popover. It never nested anything, and it was the library's last @headlessui/vue + @popperjs/core popover — @popperjs/core leaves dependencies with it. The import fails, so the build names every call site.

Tooltip — vocabulary aligned with Popover and HoverCard (breaking)

  • Breaking, silent: placement is renamed to side, matching Popover and HoverCard. An unknown prop is ignored, so the tooltip keeps working and points at its default side.
  • Breaking, silent: arrowClass is removed (P10 — no class-injection props). Style the arrow through [data-slot="arrow"]. It was documented as the arrow's fill but was mostly used to nudge the bubble's position, which the new offset prop does directly.
  • Added: offset sets the gap in px between trigger and bubble, matching Popover and HoverCard. The bubble is no longer pinned at 4px.
  • Added: [data-slot="content"], [data-slot="bubble"] and [data-slot="arrow"] styling hooks.
  • Breaking, silent: the #body slot is replaced by #content, which renders inside the bubble instead of replacing it. #body is not in P6's slot vocabulary, and it was the wrong shape: it stripped the bubble's surface, so six of the seven call sites in the apps hand-copied rounded bg-surface-gray-10 px-2 py-1 text-xs text-ink-base shadow-xl to put it back. Moving to #content usually means deleting that wrapper. Vue drops an unknown slot without an error, so a missed call site shows an empty tooltip.
  • Added: bare renders #content without the bubble shell, for content that brings its own surface — an image preview, say. The arrow still renders. This is the honest form of what #body was reached for.
  • TooltipBubble is no longer exported. It is the internal bubble shared by Tooltip and Button, with no call sites outside the library.
  • Tooltip keeps #default as the trigger, deliberately. It is the one inversion in the library, recorded under P6: over 200 call sites use the <Tooltip text="…"><Button /></Tooltip> shorthand, and renaming the slot would move every one of them for no behavioral gain.

Before/after is in the migration guide.

HoverCard — open() and close() on the template ref

  • Added: open() and close() on the component instance, matching Popover.
  • The trigger slot's props are now typed (HoverCardSlotProps) instead of any.

BottomSheet — focus stays inside an open sheet

The sheet opts out of autofocusing its first field, so it does not pop the keyboard on a phone. That also left focus on the trigger behind the overlay, with nothing holding it — Tab walked the page behind an open modal. The sheet now takes focus itself on open. The keyboard still stays down.

Divider — action.handler removed (breaking)

Per ADR-0008, action.handler is deleted, not carried forward as a warning. Use action.onClick. Zero call sites across the census. Silent break — a leftover handler is dropped as an unknown key, so the action button renders but does nothing on click. Action mode preserves separator semantics for assistive technologies.

PageHeaderBackButton — to is now a fallback (breaking)

to used to be the destination. Setting it made every tap push that route. It is now used only when there is no in-app history to go back to, such as a cold load onto a deep link. Every other tap goes back through history.

A back button that always lands on one fixed route is not a back button. It drops the user wherever the page author guessed they came from, which is wrong for every other way into the page.

Migration: nothing to do if to already named the page users came from. It now applies only on a cold load. If you need an unconditional push, use a plain Button with your own router.push.

Editor — media captions moved off alt (breaking)

Text in an image's or video's alt no longer renders as a caption. Captions live in a separate caption attribute, serialised as data-caption. alt goes back to being the screen reader description only.

Existing alt values still parse and still round-trip untouched. They just do not display as a caption any more.

The editor no longer edits alt at all. The caption field used to write it; it now writes caption, and no other control took over alt. Set it from your own content pipeline until an alt field lands.

There is deliberately no fallback from caption to alt. Stored alt values are mostly upload filenames and emoji shortcodes, and a real caption cannot be told apart from those automatically. Showing all of them is worse than showing none.

Migration: to keep a caption visible, copy the text into caption. A one-off content migration can do that where you know the old alt values were captions.

HTTP transport — four paths collapse to one (breaking)

frappeRequest is the single transport. Removed from the root export:

  • request — the bare fetch wrapper under frappeRequest, now internal. Use frappeRequest.
  • createCall — no consumers in any app.
  • initSocket — no consumers in any app; every one defines its own. socket.io-client remains a dependency (resources/realtime.ts exports functions typed against its Socket).

All three are build failures at the import.

call now honours setConfig (silent). It kept its (method, args, options) signature, the value it resolves to, and the { response, status, error } shape it hands onError, but it delegates to frappeRequest instead of building its own fetch. It had never imported getConfig, so requestBaseUrl and requestHeaders were ignored on every call() while frappeRequest respected them. Two consequences in apps that set either: call now goes to the configured base URL with credentials: 'include', and _server_messages from a call now reach serverMessagesHandler.

FrappeRequestError is now exported. frappeRequest threw it but nothing exported it, so a consumer could not type a catch.

frappeRequestonError fired twice per failure (fix)

request() attached transformError with a trailing .catch, which also caught what transformResponse threw. Every failed HTTP response therefore ran onError twice. It now runs once, because frappeRequest marks an error it has already reported rather than because the rejection handler sees less. That distinction matters: an ok response whose body will not parse — Frappe answers an expired session with 200 and its login page, so response.json() throws — is a failure only that handler sees, and it still reaches onError.

A method name starting with http skipped the /api/method/ prefix (fix). The absolute-URL check was url.startsWith('http'), which matches the four letters rather than a scheme, so http_utils.api.run was fetched as a relative path. It now matches https?://.

frappeRequest also gained an explicit return type and passes its type argument through, so frappeRequest<Foo>() resolves to Foo rather than unknown.

login returned only message when requestBaseUrl was set (fix).login is the one endpoint that resolves to the whole body, so a caller can read full_name and home_page. The check compared the whole URL against /api/method/login, and requestBaseUrl makes that URL absolute, so it stopped matching and login quietly resolved to data.message. It now matches on the path.

FrappeUI plugin — one option left (breaking)

app.use(FrappeUI) accepts resources and nothing else, and no longer installs it by default.

  • socketio removed. It defaulted to true, so apps that also built their own socket opened two live socket.io connections per page load.
  • call removed. It installed a $call global with no consumers.
  • config removed (silent). setConfig is the entry point. One app passed it.
  • resources no longer defaults to true. The v1 resources Options API mixin — this.$resources, $getResource, $getDoc, $getListResource, $refetchResource — installs only on app.use(FrappeUI, { resources: true }). Composition API resources are unaffected. resourcesPlugin stays exported for direct installation.

Because a removed option is ignored rather than rejected, the plugin logs a dev-mode warning naming any option it does not accept and what to use instead.

Removed features fail loudly, in production too. A dropped option that evaporates is an annoyance; a dropped feature that evaporates is a mystery crash somewhere else. So:

  • A component declaring a resources option without app.use(FrappeUI, { resources: true }) throws on creation, naming itself and the fix.
  • Reading this.$resources with the option off throws the same advice. Both guards exist because Vue routes what a lifecycle hook throws through its own error handling, which only logs in production — a read throws straight into app code in every build.
  • Reading this.$socket or this.$call, the two globals the plugin stopped installing, throws a message naming the replacement instead of returning undefined. Assigning your own — app.config.globalProperties.$socket = io(…) — replaces the guard, before or after app.use(FrappeUI).

realtime: true on a v1 resource still degrades quietly to a non-realtime resource when no socket is set. That has always been its behaviour and this does not change it.

Data fetching (v2) — one request per submit

useDoctype's insert, delete, setValue, runDocMethod and runMethod, and useList's insert, setValue and delete, each held a single shared request. Two submits at once aborted one another, and every submit resolved from the same data, so a caller could receive another caller's answer or null. Each submit now sends its own request and resolves with its own response.

  • Breaking: these eight members no longer carry the useCall surface. Removed: params, promise, url, reset, abort, execute, fetch, reload, isFetching, isFinished, canAbort, aborted. Each of them described one shared request, which no longer exists.
  • What is left, on all eight: submit(), data, error, loading and isLoading(). loading is true while any submit is in flight.
  • New: delete.isLoading(name) and setValue.isLoading(name) on both useDoctype and useList. This replaces the delete.loading && delete.params.name === row.name idiom, which showed the same spinner on every row once two deletes overlapped.
  • New: insert.isLoading() on both, taking no argument. A new document has no name to key on, so it answers for the whole method — the same value as insert.loading. It exists so all eight methods read the same way.
  • runDocMethod.isLoading(name, method) and runMethod.isLoading(method) keep their signatures and now answer correctly with several submits in flight. They previously compared the shared URL, so only the newest submit read as loading.
  • data and error belong to the submit that started last, not the one that answered last. A slow submit that comes back after a newer one is dropped: it writes no data, writes no error and clears nothing. It still answers its own caller with its own outcome — resolving with its response, or rejecting with its error.
  • The winning submit writes data and error together. Success sets data and clears error. Failure sets error and leaves data alone.
  • data is no longer reset on failure. The old shared useCall set data back to null whenever a response came back not-ok. It now keeps the last successful response. Read error, not data, to tell a failed submit from a successful one.
  • error is no longer cleared when a submit starts. Clearing it there erased the error of a sibling submit that was still in flight. An error stands until the newest submit settles.
  • submit() now rejects on any failure. It resolves with the response or rejects with the error — one channel, not two. A failed validate already rejected; a failed request used to resolve with null. Both reject now, and a server that answers with null resolves with null.
  • useList's insert and delete now send to baseUrl, which they silently dropped. setValue already used it, so all three write methods now agree. useDoctype was never affected — every one of its methods already passed baseUrl.

Data fetching (v2) — useFrappeFetch off the root export (breaking)

useFrappeFetch removed. It is the raw createFetch instance useCall, useDoc and useList are built on — headers, response parsing and error shaping, and nothing above that: no URL building, no params, no caching, no typed return. No app imports it. It is a build failure at the import; use useCall for a whitelisted method, useDoc for one document, useList for a query.

FrappeResponseError is now exported. The composables raise it on a Frappe error response and put it on .error, and submit() rejects with it, but nothing exported the class, so a consumer could not narrow the error. Same gap FrappeRequestError closed for frappeRequest.

Data fetching (v2) — docs, and the sidebar splits from Resources

useCall, useDoc, useList, useDoctype and useNewDoc each get a docs page for the first time, under a new Data Fetching sidebar section — useCall for a whitelisted method, useDoc for one document, useList for a query, useDoctype for write-only access to a DocType, useNewDoc for a draft-and-insert form.

The old Data Fetching section is renamed Resources and keeps its three pages (Resource, List Resource, Document Resource) unchanged. Both sections link to each other: Resources stays fully supported through 1.x; the new composables are the recommended layer for new code.

Data fetching (v2) — useNewDoc lost reactivity after submit (fix)

useNewDoc built its return value with reactive({ ...out, submit, doc }). Spreading a reactive() proxy reads every ref and computed once and freezes the result, so data, error and loading stopped updating the moment the object was built — a template bound to newDoc.loading never saw it flip. The return value is now built by mutating the underlying object in place, so its properties stay live.

Data fetching (v2) — initialData did nothing on useCall and useList (fix)

Both documented an initialData option to show a placeholder before the first response. Neither worked. useCall passed it straight to the underlying fetch, which expects the wrapped { data: ... } shape the API actually returns — the unwrapped value was invisible, so call.data stayed null until the first response. useList's data is read from a separate list built in afterFetch, which initialData never touched, so list.data stayed null the same way. Both now show the seeded value immediately, as documented.

Root composables and directives — renamed and shrunk

Every change below is a loud break: the import line fails, so the build, the type-check or the dev server says so. No silent behavior changes, and nothing here needs a migration-guide before/after.

useTheme is now useColorScheme. theme means color tone everywhere else in the library (theme="blue" on a Button, ~300 sites), so the light/dark composable stops competing for the word.

ts
// before
import { useTheme, type Theme } from 'frappe-ui'
const { currentTheme, setTheme, toggleTheme } = useTheme()

// after
import { useColorScheme, type ColorScheme } from 'frappe-ui'
const { colorScheme, setColorScheme, toggleColorScheme } = useColorScheme()
  • colorScheme is read-only. Assigning to the old currentTheme ref moved the ref without setting data-theme or localStorage, so the app silently desynced. Go through setColorScheme.
  • initializeTheme and getSystemTheme are gone. useColorScheme() already restores the saved preference and follows the OS on its first call.
  • The data-theme attribute and the theme localStorage key are unchanged. App CSS targeting [data-theme='dark'] and users' saved preferences keep working.

Nine scroll members become two.

ts
// before
import { activeScrollContainer, useScrollContainer, scrollToTop } from 'frappe-ui'
const { isScrolled } = useScrollContainer({ threshold: 12 })

// after
import { shellScrollContainer, useShellScrolled } from 'frappe-ui'
const scrolled = useShellScrolled({ threshold: 12 })
shellScrollContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })
RemovedUse instead
activeScrollContainershellScrollContainer
useScrollContainer().isScrolleduseShellScrolled()
useScrollContainer().elshellScrollContainer
getScrollContainer()shellScrollContainer.value (works outside setup() too)
scrollTo(o)shellScrollContainer.value?.scrollTo(o)
scrollToTop()shellScrollContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })
registerScrollContainer / unregisterScrollContainerinternal to DesktopShell / MobileShell
UseScrollContainer, UseScrollContainerOptionsno replacement needed

The shell prefix is deliberate: both resolve only while a DesktopShell or MobileShell is mounted. useShellScrolled now warns once in development when no shell is registered, instead of silently reporting false forever.

Directives are vFocus and vOnOutsideClick. <script setup> auto-registers a directive only when the binding is named vFoo, so the old names forced a manual alias at every call site.

vue
<!-- before -->
<script setup>
import { onOutsideClickDirective as vOnOutsideClick } from 'frappe-ui'
</script>

<!-- after -->
<script setup>
import { vOnOutsideClick } from 'frappe-ui'
</script>

visibilityDirective is removed with no replacement (0 call sites). Use an IntersectionObserver directly, or @vueuse/core's useIntersectionObserver.

useScreenSize, useIsMobile and ScreenSize are no longer exported. They were a thin wrapper over a resize listener that the library never used itself. Copy the ~20 lines into your app, or use @vueuse/core's useWindowSize / useMediaQuery.

code-editor subpath — folded into experimental (breaking)

  • Breaking: frappe-ui/code-editor is removed. CodeEditor, CodePreview, and loadLanguage move to frappe-ui/experimental (ADR-0010). One downstream file imported the old subpath; the fix is a one-line import change.

    ts
    // before
    import { CodeEditor, CodePreview } from 'frappe-ui/code-editor'
    
    // after
    import { CodeEditor, CodePreview } from 'frappe-ui/experimental'

experimental barrel — tidy and FrappeUIError

  • Breaking: LabelingWrapper is dropped from frappe-ui/experimental. It stays exported from its own barrel (src/components/InputLabeling) — Combobox, Select, MultiSelect, and MultiEmailInput import it from there internally — only the experimental re-export had zero external importers, so only that goes. The only member cut in the barrel tidy.
  • FrappeUIError is now exported from frappe-ui/experimental as a type. A consumer previously hand-declared a structural copy of it because it wasn't re-exported — that copy can now be dropped in favor of the real type.

tsconfig.base.json — cleaned up (breaking for extenders)

  • Breaking: tsconfig.base.json no longer sets types (vitest/globals, unplugin-icons/types/vue, node). If your app extends this file and relies on any of these globals, add types to your own tsconfig.json. Without it, tsc fails with a missing-global error (e.g. Cannot find name 'vi') the first time a global that used to come from vitest/globals or unplugin-icons/types/vue is referenced. noEmit stays — it's needed to keep allowImportingTsExtensions legal — but the declaration / emitDeclarationOnly pair (contradictory alongside noEmit, and unused by frappe-ui's own build) is gone.

./hljs-theme.css export removed

  • Breaking: frappe-ui/hljs-theme.css is no longer exported. It had zero importers. The underlying file (experimental/TextEditor/hljs-github.css) ships until the deprecated TextEditor is removed.

pageMetaPlugin — removed

  • Silent break: pageMetaPlugin and the global mixin it installed are gone. A leftover pageMeta() component option still compiles but is never read, so document.title and the favicon quietly stop updating. See the migration guide.
  • usePageMeta is unchanged and now exports its PageMeta type.

GridLayout — removed (breaking)

  • Breaking: GridLayout is no longer exported. It was a thin passthrough to grid-layout-plus with no docs page and no tests. The import fails, so the build names every call site. Depend on grid-layout-plus directly.
  • grid-layout-plus is dropped from dependencies — it had no other importer left in src/.
  • Two bugs in the deleted component, so consumers wiring up grid-layout-plus themselves should expect different behavior:
    • cols and rowHeight were read once at setup inside a reactive() options object, not computed, so changing either prop after mount did nothing.
    • the drag placeholder color was a hardcoded #b1b1b1, not a theme token, so it ignored dark mode.

App shell family — brought to bar

DesktopShell, MobileShell, MobileNav, Rail, PageHeader, ScrollArea, and FrappeUIProvider all keep their current exports and names.

  • Every slot across the family now has a documented description, and each component has a docs page, a story, and cypress tests (several had none).
  • Breaking, silent: PageHeaderMobile's #left/#right slots and PageHeaderMobileTitle's #icon slot are renamed to the shared #prefix/#suffix vocabulary (PHILOSOPHY.md P6 forbids type-specific slots like #icon outside Button, and #left/#right were never in the vocabulary). Vue drops content passed to an unknown slot name with no error, so the old names don't warn — they just stop rendering. See the migration guide.
  • ScrollArea gets a types.ts (ScrollAreaProps, ScrollBarProps, ScrollAreaExposed) and data-slot="scroll-area" / "scroll-area-viewport" / "scroll-area-scrollbar" / "scroll-area-thumb" styling hooks — it had none. viewportElement on the template ref is now typed via ScrollAreaExposed. (SettingsDialog's SettingsBody exposes the same shape today but isn't wired to this type yet — that's tracked under SettingsDialog's own sweep.)
  • FrappeUIProvider's source directory moved from src/components/Provider to src/components/FrappeUIProvider to match its file name. Purely internal — import { FrappeUIProvider } from 'frappe-ui' is unaffected.
  • Breaking: FrappeUIProviderProps is no longer exported. The component has no props, so the type was empty and never wired to defineProps — freezing it now would lock in nothing. Zero known consumers. The mismatched directory name had made the whole component invisible to the docs generator, so it previously had no docs page.

frappe and drive subpaths — removed (breaking)

  • Breaking: frappe-ui/frappe is removed and the frappe/ directory is deleted (rule 6: frappe-ui is a dumb library; decided in #867, moved in frappe/frappe#41671). useTelemetry, telemetryPlugin, useOnboarding, GettingStartedBanner, IntermediateStepModal, HelpModal, showHelpModal, minimize, TrialBanner, SignupBanner, DataImport, Link, Filter and LinkProps now live in @framework/ui. The Link and Filter there are supersets (Link: redirectable/editable props, redirect/edit emits; Filter: useFilters, parseFilters/serializeFilters, operator registry).
  • Breaking: OnboardingSteps, HelpCenter and showHelpCenter are removed with no standalone replacement — zero call sites across all consumer apps (they still power HelpModal inside @framework/ui).
  • Breaking: frappe-ui/drive and frappe-ui/drive/* are removed with no replacement. No app imported them — the drive app owns the live copy of all six components.
  • The content export from frappe-ui/tailwind and the docs no longer list a frappe/** glob; apps hand-maintaining node_modules/frappe-ui/frappe/** in tailwind.config.js should drop the line.

Deprecation log

APIReplacementNotes
Divider.action.handlerDivider.action.onClickRemoved — silent; key dropped, click does nothing
Password.value propv-model / modelValueRemoved in 1.0.0 (ADR-0008)
Rating.rating_from propmaxRemoved — silent; prop ignored
Rating.readonly propdisabledRemoved — silent; prop ignored
Switch.change emitupdate:modelValue / v-modelRemoved — silent; listener never fires
Switch.labelClasses propdata-* styling hooksRemoved — silent; prop ignored
Checkbox.padding proppadded / data-* styling hooksRemoved — silent; prop ignored
Dropdown { group, items }{ group, options }Removed — silent; renders empty, dev-only warning
Dropdown.placement propalignRemoved — silent; falls back to align="start"
Dropdown/ContextMenu component: rowsslots: { item: fn }Removed — silent; renders label-only row, dev-only warning
DropdownExposed typev-model:open / close slot propRemoved — loud; described an expose that never existed
Select #item-* slot prop optionitemRemoved — silent; { option } destructures to undefined
Input.vueTextInputRemoved in 1.0.0 (ADR-0008)
AutocompleteCombobox or MultiSelectRemoved — import fails
GridLayoutdepend on grid-layout-plus directlyRemoved — loud; import fails
FormControl type='autocomplete'type="combobox", or Combobox standaloneRemoved — silent; dev-only console.error
DatePicker family placementside + align + offsetRemoved — silent; inert extra attribute
DatePicker family autoClosekeepOpen (inverse)Removed — silent; inert extra attribute
DatePicker family allowCustomtypeable: falseRemoved — silent; inert extra attribute
DatePicker family readonlytypeable: falseRemoved — silent; inert extra attribute
DatePicker family inputClassclass on the component elementRemoved — silent; inert extra attribute
DatePicker family value propv-model / modelValueRemoved — silent; inert extra attribute
DatePicker family #target slot#triggerRemoved — silent; slot content stops rendering
TimePicker.scrollModenone (always centered)Removed — silent; inert extra attribute
DateTimePicker.minDateTimeminRemoved — silent; constraint no longer enforced
DateTimePicker.maxDateTimemaxRemoved — silent; constraint no longer enforced
TimePicker.minTimeminRemoved — silent; constraint no longer enforced
TimePicker.maxTimemaxRemoved — silent; constraint no longer enforced
TimePicker.selectAll() / .blurInput()none — dead, no callersRemoved — loud; template-ref member gone
useDatePicker composableuse picker components directlyRemoved — loud; import fails
getDate / getDatesAfter / etc.use picker components directlyRemoved — loud; import fails
MonthPickerSelectRemoved — loud; import fails
FeatherIconlucide-* strings (or a Component)Removed — import fails; feather-name props render nothing, dev-warns once
Cardlayout markupRemoved in 1.0.0 (ADR-0008), import fails
ListItemlayout markupRemoved in 1.0.0 (ADR-0008), import fails
Toast (SFC)imperative toast(...) APIRemoved in 1.0.0 (ADR-0008), import fails
Dialog legacy options blobflat top-level propsRemoved — silent; inert attr
Dialog disableOutsideClickToClosedismissible (inverted)Removed — silent; inert attr
Dialog #body* slots#default / #title / #actionsRemoved — silent; renders nothing
Dialog icon.appearanceicon.themeRemoved — silent; icon loses tone
Dialog action onClick callable context{ close } objectRemoved — throws on call
Dialog template-ref close()v-model:open / close slot propRemoved — throws on call
ConfirmDialog componentdialog.confirm() / dialog.danger()Removed — import fails
confirmDialog()dialog.confirm()Removed — import fails
FileUploader.uploadArgsflat props (private, folder, doctype, docname, fieldname, uploadEndpoint, optimize)Removed — silent; inert attr
FileUploader template-ref inputRefopenFileSelector slot propRemoved — throws on call
FileUploader slot prop erroralways string | null, was unknownChanged — silent; .message access renders nothing
useFileUpload / FileUploadHandler unset privacyexplicit private / is_privateDefault changed — silent; now resolves to private
fileToBase64, formatBytes, getMaxFileSize, fileSizeLimitMessagenone (internal only)Removed — import fails
frappe-ui/charts ColorScheme typeroot ResolvedColorScheme (re-exported from frappe-ui/charts)Removed — loud; type import fails