5.9.0 Change Notes

@itwin/core-bentley

BeUnorderedEvent

BeUnorderedEvent<T> and BeUnorderedUiEvent<T> are new Set-backed event classes where listeners can safely add or remove themselves during event emission. Useful for patterns where many independent subscribers need to react to the same event without worrying about concurrent modification.

Closure-only unsubscription: Unlike BeEvent, BeUnorderedEvent does not expose has() or removeListener(). The only way to unsubscribe is to call the closure returned by addListener() or addOnce():

const remove = myEvent.addListener((args) => { /* ... */ }); // later: remove(); // O(1) removal

This is intentional — it avoids a class of bugs where removeListener() silently fails to match due to inline closures or binding mismatches (e.g. event.addListener(() => this.onFoo()) followed by event.removeListener(() => this.onFoo()) removes nothing because the two arrow functions are different objects). Capturing the returned closure is a reliable unsubscription pattern and enables O(1) removal via Set.delete.

@itwin/core-backend

IdSet virtual table performance improvements

The IdSet virtual table now uses a sorted vector internally, enabling O(log n) point lookups (id = ?) and efficient single-pass IN filtering instead of full scans.

-- Point lookup — binary search, O(log n) SELECT id FROM IdSet('[1,2,3,4,5]') WHERE id = 3 -- returns: 0x3 -- IN filter — single Filter call for all values SELECT id FROM IdSet('[1,2,3,4,5,6,7,8,9,10]') WHERE id IN (3, 5, 7) -- returns: 0x3, 0x5, 0x7 -- Sorted deduplication — unsorted input with duplicates SELECT id FROM IdSet('[50,10,30,20,40,10]') -- returns: 0xa, 0x14, 0x1e, 0x28, 0x32

Output is always returned in ascending ID order and deduplicated, regardless of input order. This is a behavioral change — previously, output order was unspecified.

WithQueryReader API

A new withQueryReader method has been added to both ECDb and IModelDb, providing true row-by-row behavior for ECSQL queries with synchronous execution. This API introduces a new ECSqlSyncReader through the ECSqlRowExecutor and supports configuration via SynchronousQueryOptions.

Key Features:

  • True row-by-row streaming: Unlike the existing async reader APIs, withQueryReader provides synchronous row-by-row access to query results
  • Consistent API across databases: The same interface is available on both ECDb and IModelDb instances
  • Configurable behavior: Support for various query options through SynchronousQueryOptions

Usage Examples:

// ECDb usage db.withQueryReader("SELECT ECInstanceId, UserLabel FROM bis.Element LIMIT 100", (reader) => { while (reader.step()) { const row = reader.current; console.log(`ID: ${row.id}, Label: ${row.userLabel}`); } }); // IModelDb usage with options iModelDb.withQueryReader( "SELECT ECInstanceId, CodeValue FROM bis.Element", (reader) => { while (reader.step()) { const row = reader.current; processElement(row); } } );

Migration from deprecated APIs:

This API serves as the recommended replacement for synchronous query scenarios previously handled by the deprecated ECSqlStatement for read-only operations:

// Before - using deprecated ECSqlStatement db.withPreparedStatement(query, (stmt) => { while (stmt.step() === DbResult.BE_SQLITE_ROW) { const row = stmt.getRow(); processRow(row); } }); // Now - using withQueryReader db.withQueryReader(query, (reader) => { while (reader.step()) { const row = reader.current; processRow(row); } });

getAvailableCoordinateReferenceSystems now returns the linear unit used by each available coordinate reference system in its unit property. The same API also accepts an optional unit filter, letting applications narrow the returned CRS list by unit name without applying client-side filtering after the fact.

Unit filtering is case-insensitive. Use the new getAvailableCRSUnits helper to retrieve the canonical unit names recognized by the backend.

const units = getAvailableCRSUnits(); const usFootSystems = await getAvailableCoordinateReferenceSystems({ includeWorld: true, unit: "ussurveyfoot", }); for (const crs of usFootSystems) { console.log(`${crs.name}: ${crs.unit}`); }

Bulk element deletion with deleteElements

EditTxn.deleteElements is a @beta API that efficiently deletes many elements in a single native operation when removing trees of elements, partitions, or mixes of ordinary and definition elements. It is intended as the preferred replacement for EditTxn.deleteElement.

What it does that deleteElement does not:

  • Automatically cascades into the full parent-child subtree of every requested element — you only need to pass root IDs.
  • Cascades into sub-models: deleting a partition element also removes the entire sub-model and all elements inside it.
  • Handles intra-set constraint violations without failing.
  • Handles DefinitionElement usage checks inline — no need to call deleteDefinitionElements separately.
  • Returns a structured BulkDeleteElementsResult describing which IDs could not be deleted due to constraint violations (e.g. code-scope dependencies held by elements outside the delete set), rather than throwing.
  • Better performance when deleting elements in bulk.

Return type — BulkDeleteElementsResult

The return value is a BulkDeleteElementsResult with three fields:

Field Description
status BulkDeleteElementsStatus: Success, PartialSuccess, or DeletionFailed
failedIds Id64Set of element IDs that could not be deleted
sqlDeleteStatus Raw DbResult from the underlying SQL DELETE statement

Basic usage:

const result: BulkDeleteElementsResult = txn.deleteElements([idA, idB, idC]); if (result.status === BulkDeleteElementsStatus.Success) { // All elements were deleted. } else { // Some or all elements could not be deleted — inspect result.failedIds. }

Performance option — skipFKConstraintValidations

Pass { skipFKConstraintValidations: true } via BulkDeleteElementsArgs to skip the pre-deletion On Delete No-Action foreign-key constraint validation pass. This can significantly improve throughput for very large deletions where you know the supplied IDs are self-consistent and free of external FK dependencies:

// Only safe when you are certain no element in the batch is referenced from outside the batch. const result = txn.deleteElements(ids, { skipFKConstraintValidations: true });

If any element in the batch is externally referenced the SQL DELETE will fail entirely (DeletionFailed) and all changes will need to be rolled back.

Bulk element deletion lifecycle callbacks

To avoid firing one notification per element (which dominated runtime for large deletions), three new batch-scoped @beta callbacks have been added:

Callback Fires on
Element.onBulkDeleted (OnBulkDeletedBatchArg) Once per Element ECClass, after all elements of that class have been deleted
Element.onBulkChildDeleted (OnBulkChildDeletedBatchArg) Once per parent ECClass, for children whose parent was not itself deleted
Model.onBulkModelEvents (OnBulkModelEventsArg) Once per Model ECClass, combining sub-model deletions and per-model element deletions

The default implementations of all three fall back to the existing single-element callbacks (onDeleted, onChildDeleted, onDeletedElement, …) so existing overrides continue to work without modification.

See Bulk Element Deletion for full documentation including constraint violation details and lifecycle callback behavior.

Dedicated SettingsDb for workspace settings

A new SettingsDb type has been added to the workspace system, providing a dedicated database for storing JSON settings as key-value pairs, separate from general-purpose WorkspaceDb resource storage.

Why SettingsDb?

Previously, settings and binary resources (fonts, textures, templates) were stored together in WorkspaceDb containers. This coupling created issues:

  • Lookup: Finding which containers hold settings required opening each one
  • Granularity: Settings updates required republishing entire containers with large binary resources
  • Separation of concerns: Settings (JSON key-value) and resources (binary blobs) have different access patterns

New APIs

  • SettingsDb: Read-only interface with getSetting() and getSettings() for accessing settings stored in a dedicated database
  • EditableSettingsDb: Write interface with updateSetting(), removeSetting(), and updateSettings() for modifying settings within a SettingsDb
  • SettingsEditor: Write interface for creating and managing SettingsDb containers
  • Workspace.getSettingsDb: Method to open a SettingsDb from a previously-loaded container by its containerId and desired priority

Usage examples

See SettingsDb for full documentation.

Container type convention

SettingsDb containers use containerType: "settings" in their cloud metadata, enabling them to be discovered independently of any iModel.

Container separation and lock isolation

Settings containers are deliberately separate from workspace containers. Both extend the new CloudSqliteContainer base interface, but EditableSettingsCloudContainer does not extend WorkspaceContainer. This means:

  • Independent write locks: Editing settings does not lock out workspace resource editors, and vice versa.
  • Clean API surface: Settings containers do not inherit workspace-db read/write methods (getWorkspaceDb, addWorkspaceDb, etc.), exposing only settings-specific operations.
  • Type safety: Code that receives an EditableSettingsCloudContainer cannot accidentally add or retrieve WorkspaceDbs from it.

Lock-aware transaction undo/redo

TxnManager now provides @beta async methods that automatically manage lock lifecycle when reversing, reinstating, or canceling transactions. Previously, undoing a transaction retained all associated locks on IModelHub, even though the changes were no longer active locally. The new async methods abandon those locks so that other users can acquire them while the changes are reversed, and re-acquire them before reinstating.

New async TxnManager methods

Method Description
TxnManager.reverseSingleTxnAsync Reverse the most recent operation and abandon its locks.
TxnManager.reverseTxnsAsync Reverse multiple operations and abandon their locks.
TxnManager.reverseAllTxnsAsync Reverse all operations back to the beginning of the session.
TxnManager.reverseToTxnAsync Reverse all operations back to a saved TxnIdString.
TxnManager.cancelToTxnAsync Reverse and permanently cancel operations back to a TxnIdString.
TxnManager.reinstateTxnAsync Re-acquire locks and reinstate the most recently reversed transaction.

Each reverse/cancel method accepts an optional ReverseTxnArgs with a retainLocks flag. When retainLocks is true, the method behaves like the existing synchronous counterpart and does not release locks. When false or omitted, locks acquired during the reversed transactions are abandoned on IModelHub.

TxnManager.reinstateTxnAsync accepts optional ReinstateTxnArgs to control whether locks for any outstanding unsaved changes are retained or abandoned before the reinstatement.

New LockControl methods

LockControl gains several @beta methods that support the async TxnManager methods:

Backward compatibility

The existing synchronous reverse and cancel methods (reverseSingleTxn, reverseTxns, reverseAll, cancelTo) remain unchanged and continue to retain all locks. The synchronous reinstateTxn also remains available, but note that it will return LockNotHeld if the locks for the reversed transaction were previously abandoned by one of the new async reverse/cancel methods. In that case, use TxnManager.reinstateTxnAsync instead, which automatically re-acquires the necessary locks before reinstating.

Example

// Undo the last operation and release its locks so other users can edit those elements await txnManager.reverseSingleTxnAsync(); // Later, re-acquire the locks and redo the operation await txnManager.reinstateTxnAsync(); // Undo and retain locks (same behavior as synchronous reverseSingleTxn) await txnManager.reverseSingleTxnAsync({ retainLocks: true });

@itwin/core-frontend

Unified reality model iteration

ViewState.getRealityModelTreeRefs is a new @beta generator method that yields all reality models visible in a view as ViewRealityModel objects. It unifies iteration over both context reality models (attached to the DisplayStyleState) and persistent reality models (in the model selector), so callers no longer need to query each source separately.

Each yielded ViewRealityModel provides:

Context reality models marked invisible are excluded. Persistent reality models whose tile trees have not yet loaded are also excluded, because identifying a model as a reality model requires the loaded tile tree.

for (const { treeRef, name, description } of view.getRealityModelTreeRefs()) { console.log(`${name}: ${description}`); }

PerModelCategoryVisibility performance improvement

The internal data structure backing PerModelCategoryVisibility.Overrides has been changed from a SortedArray to a nested Map + Set, improving performance in some cases more than 10x.

The [Symbol.iterator] on PerModelCategoryVisibility.Overrides now yields entries in insertion order instead of sorted by (modelId, categoryId). The set of yielded entries is identical - only the order has changed.

Backend

ChangesetReader — native changeset reader

The new ChangesetReader (@beta) provides a lower-level, higher-fidelity replacement for the deprecated ChangesetECAdaptor / SqliteChangesetReader stack. It reads EC-typed change data natively from a changeset file, a group of changeset files, a saved transaction, or local un-pushed changes, and emits one typed ChangeInstance per SQLite table row.

The companion PartialChangeUnifier merges the per-table partial rows back into complete EC instances that span all tables mapped to a single EC entity.

Reader factory methods

Method Description
ChangesetReader.openFile Read a single pushed changeset file
ChangesetReader.openGroup Read several changeset files as one logical stream
ChangesetReader.openLocalChanges Read pending (not yet pushed) local changes
ChangesetReader.openInMemoryChanges Read in-memory (not yet saved) changes
ChangesetReader.openTxn Read a single saved transaction by id

Example — inspect inserted elements from a changeset file

import { ChangesetReader, PartialChangeUnifier, ChangeUnifierCache } from "@itwin/core-backend"; using reader = ChangesetReader.openFile({ db: iModelDb, fileName: changesetPathname, rowOptions: { classIdsToClassNames: true }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) { pcu.appendFrom(reader); } for (const instance of pcu.instances) { if (instance.$meta.op === "Inserted") { console.log(instance.ECInstanceId, instance.ECClassId); } }

Example — filter to a specific table and inspect raw per-row values

using reader = ChangesetReader.openFile({ db: iModelDb, fileName: changesetPathname }); reader.setOpCodeFilters(new Set(["Updated"])); while (reader.step()) { if (reader.tableName !== "bis_Element") continue; const before = reader.deleted; // pre-change snapshot const after = reader.inserted; // post-change snapshot if (before && after) { console.log(`Element ${after.ECInstanceId}: model changed from ${before.Model?.Id} → ${after.Model?.Id}`); } }

When a row does not match an active filter it is skipped entirely — the reader automatically advances to the next row.

ChangesetReader.deleted and ChangesetReader.inserted carry a $meta.changeFetchedPropNames array that lists exactly which properties were read directly from the changeset binary (vs. resolved from the live iModel), making it straightforward to determine what actually changed.

For a full explanation of the reader–unifier pipeline, modes, row options, and filtering APIs, see ChangesetReader.

Migrating from ChangesetECAdaptor

The deprecated ChangesetECAdaptor / PartialECChangeUnifier stack is replaced by ChangesetReader + PartialChangeUnifier.

Basic pipeline — read and merge a changeset file

Before (deprecated)

// SqliteChangesetReader requires disableSchemaCheck: true for use with ChangesetECAdaptor const reader = SqliteChangesetReader.openFile({ db: iModelDb, fileName, disableSchemaCheck: true }); const adaptor = new ChangesetECAdaptor(reader); const unifier = new PartialECChangeUnifier(iModelDb); while (adaptor.step()) unifier.appendFrom(adaptor); for (const instance of unifier.instances) { if (instance.$meta?.op === "Inserted") console.log(instance.ECInstanceId); } unifier[Symbol.dispose](); adaptor[Symbol.dispose](); // also closes the underlying SqliteChangesetReader

After (new API)

using reader = ChangesetReader.openFile({ db: iModelDb, fileName }); using pcu = new PartialChangeUnifier(); while (reader.step()) pcu.appendFrom(reader); for (const instance of pcu.instances) { if (instance.$meta.op === "Inserted") console.log(instance.ECInstanceId); }
SQLite-backed cache for large changesets

Before (deprecated)

// The old SQLite cache reused the live iModel's connection and wrote to a [temp] table on it using cache = ECChangeUnifierCache.createSqliteBackedCache(iModelDb); const unifier = new PartialECChangeUnifier(iModelDb, cache);

After (new API)

// The new cache opens its own private temporary SQLite database — no live iModel connection needed using cache = ChangeUnifierCache.createSqliteBackedCache(); using pcu = new PartialChangeUnifier(cache);
Filtering

Before (deprecated) — fluent, class-name-based

adaptor .acceptOp("Inserted") .acceptTable("bis_Element") .acceptClass("BisCore:Element"); // expands to all derived class ids internally

After (new API)Set-based, class-id-based

reader.setOpCodeFilters(new Set(["Inserted"])); reader.setTableNameFilters(new Set(["bis_Element"])); reader.setClassNameFilters(new Set(["BisCore:Element"])); // full "SchemaName:ClassName" format, doesnot expand to any derived classes
Key differences
Concern Old (ChangesetECAdaptor) New (ChangesetReader)
Opening SqliteChangesetReader.openFile + new ChangesetECAdaptor(reader) ChangesetReader.openFile directly
disableSchemaCheck flag Required (true) on SqliteChangesetReader Not needed — schema check is built in
Merging multi-table entities new PartialECChangeUnifier(db) + unifier.appendFrom(adaptor) new PartialChangeUnifier() + pcu.appendFrom(reader)
PartialECChangeUnifier db arg Required (uses live iModel connection for temp table) Not needed (new unifier owns its temp db)
SQLite cache db arg ECChangeUnifierCache.createSqliteBackedCache(db) — reuses iModel connection ChangeUnifierCache.createSqliteBackedCache() — self-contained
Resource management Manual [Symbol.dispose]() on each object using declaration handles everything
Filtering by class acceptClass(fullName) — automatically expands to all derived class ids, so a single class name filters the entire hierarchy setClassNameFilters(Set<string>) — exact match only; does not expand to derived classes; pass each class name explicitly in "SchemaName:ClassName" format
Filtering API style Fluent (.acceptOp(...).acceptTable(...)) Setter methods with Set<> arguments
$meta on instances Optional (disableMetaData flag could suppress it) Always present
Changed property tracking Not available $meta.changeFetchedPropNames lists exactly which properties came from the changeset binary
Null-valued properties Null values were included as keys on instance objects (key present, value null) Null values are not included as keys — a property absent from the instance object means its stored value was null; use $meta.changeFetchedPropNames to distinguish "not changed" from "changed to/from null"

Explicit editing transactions with EditTxn

The backend now provides EditTxn as the preferred way to perform writes to an iModel. This introduces an explicit transaction boundary around a unit of work: start editing, make one or more changes through the transaction, and then either save or abandon that scope.

This change is meant to replace the long-standing implicit write pattern in which APIs that add, delete, or modify database content wrote through an always-available transaction owned by the iModel. That implicit model remains available for backwards compatibility during migration, but all legacy APIs that add, delete, or modify database content are now deprecated in favor of txn-first overloads and helper APIs built around EditTxn.

What changed

  • New explicit-write APIs are available across backend editing surfaces, including elements, models, relationships, aspects, file properties, etc.
  • Existing implicit-write APIs remain available for now, and all are now marked @deprecated and point to txn-first replacements.
  • withEditTxn provides a convenient scoped wrapper that starts an EditTxn, passes it to a callback, saves on success, and abandons on failure.
  • Indirect-change callbacks now receive the active EditTxn via callback args (indirectEditTxn), such as OnDependencyArg and OnElementDependencyArg.

Migration guidance

When updating existing code:

  1. Replace calls to legacy APIs that add, delete, or modify database content (for example insert(), update(), delete(), saveChanges(), and container-specific insert/update/delete helpers) with the corresponding txn-first overloads.
  2. Group related edits into a single EditTxn so they succeed or fail together.
  3. Prefer withEditTxn for new code unless manual start() / end() control is necessary.
  4. If code runs inside indirect dependency processing callbacks, use the callback argument's indirectEditTxn for indirect changes.

Before:

const modelId = PhysicalModel.insert(iModel, parentSubjectId, "My Model"); const element = MySpatialElement.create({ model: modelId, category, code }, iModel); element.insert(); iModel.saveChanges("Create model contents");

After:

withEditTxn(iModel, "Create model contents", (txn) => { const modelId = PhysicalModel.insert(txn, parentSubjectId, "My Model"); const element = MySpatialElement.create({ model: modelId, category, code }, iModel); element.insert(txn); });

A single EditTxn can create multiple saved transactions, because txn.saveChanges does not end the transaction:

const txn = new EditTxn(iModel, "Create model contents"); txn.start(); const modelId = PhysicalModel.insert(txn, parentSubjectId, "My Model"); txn.saveChanges("Saved first batch"); // Commits current edits and keeps this EditTxn active. const element = MySpatialElement.create({ model: modelId, category, code }, iModel); element.insert(txn); txn.end("save", "Saved second batch and closed transaction");

implicitWriteEnforcement

EditTxn.implicitWriteEnforcement and IModelHostOptions.implicitWriteEnforcement control how legacy implicit writes behave while callers migrate:

Value Behavior
"allow" Preserve existing behavior and allow implicit writes.
"log" Allow implicit writes, but emit implicit-txn-write-disallowed errors to help inventory remaining migration work.
"throw" Reject implicit writes and require explicit EditTxn usage.

These levels are intended to support incremental adoption. Applications can start with "allow", move to "log" to discover remaining legacy paths, and then switch to "throw" once those call sites have been migrated.

For more guidance and additional examples, see EditTxn transaction model and migration guidance.

iTwin settings workspace

Applications can now store and load named settings dictionaries in an iTwin-scoped workspace, separate from iModel-level settings so the same values can be shared across iModels in that iTwin.

Under the hood, that workspace uses a SettingsDb, which is a settings-formatted WorkspaceDb named settings-db. In that db, each string resource is one settings dictionary:

  • Resource name: dictionary name
  • Resource value: JSON dictionary content

Developers still read and write settings dictionaries by name, while container management, versioning, and cloud sync follow the standard workspace model.

New APIs

These methods read and write dictionaries in the underlying SettingsDb. The dictionary name becomes the resource name, allowing multiple independent dictionaries to coexist in the same container. This mirrors the existing IModelDb.saveSettingDictionary / IModelDb.deleteSettingDictionary pattern.

Usage examples

Save a settings dictionary to an iTwin:

IModelHost.settingsSchemas.addGroup({ schemaPrefix: "myApp", description: "MyApp settings", settingDefs: { defaultView: { type: "string" }, maxDisplayedItems: { type: "integer" }, }, });

await IModelHost.saveSettingDictionary(iTwinId, "myApp/settings", { "myApp/defaultView": "plan", "myApp/maxDisplayedItems": 100, });

Read it back:

const iTwinWorkspace = await IModelHost.getITwinWorkspace(iTwinId); const defaultView = iTwinWorkspace.settings.getString("myApp/defaultView"); iTwinWorkspace.close();

Delete it:

await IModelHost.deleteSettingDictionary(iTwinId, "myApp/settings");

Configuration requirements

To use iTwin-scoped settings dictionaries, configure IModelHost.authorizationClient and BlobContainer.service so the backend can query and update the iTwin settings workspace container.

See the Settings documentation for full details on iTwin-scoped settings and the Workspace documentation for workspace resources.

Quantity Formatting

Quantity Formatter improvements

New APIs

@itwin/core-quantity
  • Units namespace — Typed constants for commonly used unit names (e.g., Units.M, Units.FT, Units.RAD). Eliminates magic strings when referencing units programmatically.
  • findPersistenceUnitForPhenomenon() — Maps phenomenon names to their canonical SI persistence unit.
  • FormatsChangedArgs.impliedUnitSystem — Allows a FormatsProvider to indicate which unit system its format set implies.
  • BasicUnitsProvider — Standalone UnitsProvider backed by the full BIS Units schema bundled into @itwin/core-quantity. It works without an iModel or SchemaContext, making the standard BIS unit set available in frontend, backend, and tool workflows.
  • createUnitsProvider and CreateUnitsProviderOptions — Factory and options for composing a primary provider, such as SchemaUnitProvider, with the bundled BIS units. bisUnitsPolicy controls whether schema-defined units or bundled BIS units win when both define the same unit.
@itwin/core-frontend

BasicUnitsProvider and createUnitsProvider

@itwin/core-quantity now exposes BasicUnitsProvider and createUnitsProvider for layered unit resolution.

QuantityFormatter now defaults to BasicUnitsProvider — the zero-dependency provider backed by the full BIS Units schema. This means:

  • All BIS units are available out of the box, covering phenomena such as length, area, volume, temperature, pressure, angle, force, and more.
  • No iModel is required for standard BIS unit support, so UIs and workflows that do not need an iModel can still format and parse quantities correctly.
  • BasicUnitsProvider can now be imported directly from @itwin/core-quantity for backend and tool scenarios that only need standard BIS units.
import { BasicUnitsProvider } from "@itwin/core-quantity"; const provider = new BasicUnitsProvider(); const meter = await provider.findUnitByName("Units.M"); const foot = await provider.findUnitByName("Units.FT"); const conv = await provider.getConversion(meter, foot); // conv.factor ~= 3.28084
Layering schema units on top of basic units

The new createUnitsProvider factory composes a primary provider, such as SchemaUnitProvider, with the bundled BIS units as a fallback:

import { IModelApp } from "@itwin/core-frontend"; import { createUnitsProvider } from "@itwin/core-quantity"; import { SchemaUnitProvider } from "@itwin/ecschema-metadata"; const iModelConnection = ...; // an open IModelConnection await IModelApp.quantityFormatter.setUnitsProvider( createUnitsProvider({ primary: new SchemaUnitProvider(iModelConnection.schemaContext), }), );

Precedence rules:

  • primary wins by default (bisUnitsPolicy: "preferSchema"): the primary provider resolves first, and bundled BIS units fill any gaps.
  • bisUnitsPolicy: "preferBundled": bundled BIS units win, and the primary provider is only consulted when the bundled provider cannot answer.
  • getUnitsByFamily always merges results from both providers, deduplicated by fully-qualified unit name.

When no primary is supplied, createUnitsProvider() returns a plain new BasicUnitsProvider() rather than a wrapper, preserving instanceof checks.

Migration for backend and tool consumers

Backends or tools that previously needed a SchemaContext only to resolve standard BIS units can now use BasicUnitsProvider directly:

import { BasicUnitsProvider } from "@itwin/core-quantity"; const provider = new BasicUnitsProvider();

Note: UnitConversionProps can mark unresolved conversions with error. QuantityFormatter, FormatterSpec, and Parser now log warnings when a provider returns one of these fallback conversions, and direct consumers of UnitConversionProps should check that flag as well.

Bug fixes

  • Fixed listener leak in FormatsProviderManager — Replacing IModelApp.formatsProvider multiple times no longer stacks listeners. Old listeners are properly removed before new ones are added.

Behavioral changes

  • Three-level spec registry_formatSpecsRegistry is now keyed by KoQ name, persistence unit, and unit system ([koqName][persistenceUnit][unitSystem]). The same KoQ with different persistence units or different unit systems can coexist. This is a protected member type change — subclasses accessing this field directly will need to update.
  • getSpecsByName() return type changed — Now returns Map<string, FormattingSpecEntry> | undefined instead of FormattingSpecEntry | undefined.
  • Two-phase ready flow — Formatting readiness now follows a two-phase pattern: onBeforeFormattingReady fires first (providers register async work via the collector), and the formatter awaits all pending work with a 20-second default timeout before emitting onFormattingReady. Rejections are logged as warnings but do not prevent the formatter from becoming ready.
  • QuantityFormatter now defaults to bundled BIS unitsIModelApp.quantityFormatter now initializes with BasicUnitsProvider from @itwin/core-quantity instead of the previous limited internal frontend provider. Formatting and parsing therefore work out of the box in non-iModel scenarios with the full BIS unit set, while apps can still layer schema-defined units via createUnitsProvider.
  • Failed unit conversions are now surfaced in logsUnitConversionProps can mark unresolved conversions with error, and QuantityFormatter, FormatterSpec, and Parser now log warnings when a provider returns one of these fallback conversions instead of failing silently.

For detailed usage documentation and code examples, see QuantityFormatter Lifecycle & Integration.

Last Updated: 04 May, 2026