Skip to content

[quantity-formatting] Optional unitsProvider, startup race fix, missing peers, exported prop types - #1828

Open
hl662 wants to merge 9 commits into
nam/qf-2-format-fixesfrom
nam/qf-3-tech-debt
Open

hl662 wants to merge 9 commits into
nam/qf-2-format-fixesfrom
nam/qf-3-tech-debt

Conversation

@hl662

@hl662 hl662 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

TL;DR

The package had cleanup items from the April refactor backlog that affect consumers:

  • Concurrent startup() calls could race.
  • Public types referenced packages that weren't declared as peer dependencies.
  • Component prop types weren't exported.
  • Every consumer had to pass a unitsProvider and track changes to it themselves.
  • Several errors were silently swallowed.

This PR fixes those, makes unitsProvider optional (it defaults to IModelApp.quantityFormatter.unitsProvider), and turns on unused-code checks. Minor release.

Stacked on #1827.

What

Asset Why it exists
QuantityFormatting.startup / terminate Concurrent startup() calls registered the namespace twice, and a terminate() during startup could corrupt a later startup. Calls now share one in-flight startup, a counter that terminate() bumps makes old startups no-ops, a failed startup can be retried, and reading localization before startup throws a clear error.
unitsProvider optional on QuantityFormatPanel / FormatPanel / FormatSample Most apps pass IModelApp.quantityFormatter.unitsProvider and re-subscribe to onUnitsProviderChanged by hand. The components now do that by default; an explicit provider still wins. Supported core stays at ^5.3.
internal/useUnitsProvider.ts (new) The single place that resolves the default provider and follows app-level changes.
Peer dependencies @itwin/core-common, @itwin/ecschema-metadata (^5.3.0) Public types already import from both, so consumers without them failed type-checking.
Exported *Props types Consumers couldn't type wrapper components without reaching into internal paths.
QuantityFormatPanel Reloads the persistence unit only when the unit name changes rather than on every edit, logs lookup failures, and documents when onFormatChange fires.
FormatSample Logs formatter creation failures instead of silently showing nothing.
FormatSetSelector Fallback keys for unnamed sets used the filtered index, so selection jumped to another set while searching. Now uses the original index.
FormatSetPanel Starts from the provided format set instead of empty values that are then filled in by an effect.
tsconfig.json noUnusedLocals / noUnusedParameters Dead handlers and imports had piled up unnoticed. The flags keep them out.
test-viewer quantity-formatting components Drop their two copies of the units-provider subscription and rely on the new default.
Changesets Minor entries for the package contract changes and the optional unitsProvider.

Tests

New and updated test coverage
Test What it covers
QuantityFormatting.test.ts — concurrent startup The namespace is registered once when startup() is called in parallel.
QuantityFormatting.test.ts — localization before startup Throws a descriptive error instead of an undefined access.
QuantityFormatting.test.ts — retry after failure A failed namespace registration doesn't block later startups.
QuantityFormatting.test.ts — startup completing after terminate A stale startup can't overwrite state from a newer one.
useUnitsProvider.test.ts — default / follows app / explicit wins Default provider resolution and updates via onUnitsProviderChanged.
FormatSetSelector.test.tsx — stable keys while filtering Selecting an unnamed set during search keeps it highlighted.
QuantityFormatPanel.test.tsx — persistence unit reload / logging No reload on unrelated edits; lookup failures are logged.
QuantityFormatPanel.test.tsx — prop change resets edits Changing formatDefinition discards pending edits without saving.

Validation

tsc, pnpm lint, pnpm test (204/204), pnpm build, extract-api, check-extractions, the test-viewer type-check, and the learning-snippets quantity-formatting tests all pass.


Nambot 🤖 (powered by claude-opus-5.5)

hl662 added 4 commits October 7, 2026 19:38
…, exported prop types, error logging, stale keys

- QuantityFormatting.startup shares one in-flight promise; localization getter throws a descriptive error before startup
- Add @itwin/core-common and @itwin/ecschema-metadata peers (public types already depend on them)
- Export component prop types and document onFormatChange timing
- Log formatter/persistence-unit failures instead of swallowing them
- FormatSetSelector keys no longer shift while searching; FormatSetPanel initializes from props
- Persistence unit reload keyed on unit name; remove dead code and unused imports; enable noUnusedLocals
- Guard QuantityFormatting startup completions with a generation so terminate() invalidates in-flight work; document first-call-wins localization
- Tests: deferred startup race, FormatSetSelector stable keys while filtering, persistence-unit reload/logging
…elApp.quantityFormatter.unitsProvider

The components follow onUnitsProviderChanged when the prop is omitted, so test-viewer drops its two copies of that wiring.
@hl662
hl662 requested review from a team as code owners October 8, 2026 03:00
@hl662
hl662 added this pull request to stack #1829 October 8, 2026 03:01
Copilot AI balanced review requested due to automatic review settings October 8, 2026 13:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

A startup completing after termination can leave its localization namespace registered indefinitely.

1 open finding
What changed in this PR

Improves quantity-formatting initialization, provider defaults, public types, diagnostics, and cleanup.

Changes:

  • Makes unit providers optional and reactive to application-level changes.
  • Prevents concurrent startup races and exports component prop types.
  • Adds peer dependencies, diagnostics, tests, and unused-code checks.
File Description
.changeset/​@itwin-quantity-formatting-react-package-contract.md Records package contract changes.
.changeset/​@itwin-quantity-formatting-react-optional-units-provider.md Records optional provider support.
apps/​test-viewer/​src/​components/​quantity-formatting/​FormatPropertyContextMenuItem.tsx Uses the default units provider.
apps/​test-viewer/​src/​components/​quantity-formatting/​FormatTabPanel.tsx Removes explicit provider plumbing.
apps/​test-viewer/​src/​components/​quantity-formatting/​QuantityFormatButton.tsx Removes duplicated provider subscription.
packages/​quantity-formatting/​README.md Documents optional providers.
packages/​quantity-formatting/​api/​quantity-formatting-react.api.md Updates extracted API declarations.
packages/​quantity-formatting/​api/​quantity-formatting-react.exports.csv Adds exported prop types.
packages/​quantity-formatting/​package.json Adds missing peer dependencies.
packages/​quantity-formatting/​src/​QuantityFormatting.ts Coordinates startup and localization state.
packages/​quantity-formatting/​src/​components/​quantityformat/​FormatPanel.tsx Supports the default provider and exports props.
packages/​quantity-formatting/​src/​components/​quantityformat/​FormatSample.tsx Supports defaults and logs formatter failures.
packages/​quantity-formatting/​src/​components/​quantityformat/​FormatSelector.tsx Exports selector props.
packages/​quantity-formatting/​src/​components/​quantityformat/​FormatSetPanel.tsx Initializes supplied values and exports props.
packages/​quantity-formatting/​src/​components/​quantityformat/​FormatSetSelector.tsx Stabilizes fallback keys and exports props.
packages/​quantity-formatting/​src/​components/​quantityformat/​QuantityFormatPanel.tsx Resolves providers and optimizes unit loading.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​FractionDash.tsx Removes an unused handler.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​StationBaseFactor.tsx Removes an unnecessary type escape.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​misc/​DecimalPrecision.tsx Removes an unused import.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​misc/​DecimalSeparator.tsx Removes an unused import.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​misc/​FractionPrecision.tsx Removes an unused import.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​misc/​SignOption.tsx Removes an unused import.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​misc/​StationSizeSelector.tsx Removes an unused import.
packages/​quantity-formatting/​src/​components/​quantityformat/​internal/​useUnitsProvider.ts Adds provider resolution and subscription.
packages/​quantity-formatting/​src/​quantity-formatting-react.ts Exports public prop types.
packages/​quantity-formatting/​src/​test/​QuantityFormatting.test.ts Tests startup lifecycle behavior.
packages/​quantity-formatting/​src/​test/​_Setup.test.ts Removes an unused test import.
packages/​quantity-formatting/​src/​test/​quantityformat/​FormatSample.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​FormatSetSelector.test.tsx Tests stable filtered selection.
packages/​quantity-formatting/​src/​test/​quantityformat/​QuantityFormatPanel.test.tsx Tests reset, loading, and logging behavior.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​DecimalSeparator.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​FormatPrecision.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​FractionDash.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​KeepDecimalPoint.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​KeepSingleZero.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​ShowTrailingZeros.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​SignOption.test.tsx Removes unused test imports.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​StationBaseFactor.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​ThousandsSeparator.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​ZeroEmpty.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​internal/​useUnitsProvider.test.ts Tests provider resolution and updates.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​AzimuthPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​BearingPanel.test.tsx Removes unused test imports.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​DecimalPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​FractionalPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​RatioPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​ScientificPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​src/​test/​quantityformat/​panels/​StationPanel.test.tsx Removes an unused import.
packages/​quantity-formatting/​tsconfig.json Enables unused-code checks.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread packages/quantity-formatting/src/QuantityFormatting.ts Outdated
hl662 added 2 commits October 8, 2026 10:18
…ration that completes after terminate

Keeps the registration when a newer startup reuses the same localization instance.
@grigasp

grigasp commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Component prop types weren't exported

Why should they be exported?

Comment on lines 62 to 82
private static async initialize(localization: Localization, generation: number): Promise<void> {
// Assigned before awaiting so components rendered while the namespace loads keep working.
QuantityFormatting._localization = localization;
try {
await localization.registerNamespace(QuantityFormatting._i18nNamespace);
} catch (error) {
// Allow a retry, unless terminate() already reset the state for a newer startup.
if (generation === QuantityFormatting._generation) {
QuantityFormatting._startupPromise = undefined;
QuantityFormatting._localization = undefined;
}
throw error;
}
if (generation === QuantityFormatting._generation) {
QuantityFormatting._isInitialized = true;
return;
}
// terminate() ran while registering. Undo this registration unless a newer startup uses the same instance.
if (localization !== QuantityFormatting._localization)
localization.unregisterNamespace(QuantityFormatting._i18nNamespace);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do you need all this? why would anyone call startup more than once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fair, there was a bunch of state management I was overthinking and overenginnering when planning this out...

Okay I simplified it so we just leverage the _startupPromise, and if startup is called again while it runs (albeit i don't see why this would happen if startup is quick) then that will reuse the current Promise

return IModelApp.quantityFormatter.onUnitsProviderChanged.addListener(update);
}, [unitsProvider]);

return unitsProvider ?? defaultProvider ?? IModelApp.quantityFormatter.unitsProvider;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a bit confusing as defaultProvider may be either based on explicit unitsProvider or IModelApp.quantityFormatter.unitsProvider, and we're mixing all three of them together here. AI suggests there's also an edge case, where we'd return a stale value:

Step Explicit prop App default Cached default Hook returns
Initial render undefined A A A
Supply explicit provider Custom A A Custom
App replaces its default Custom B A Custom
Remove explicit provider undefined B A A, stale
Effect updates state undefined B B B

I would consider something like this:

function subscribeToUnitsProvider(onStoreChange: () => void) {
  return IModelApp.quantityFormatter.onUnitsProviderChanged.addListener(
    onStoreChange,
  );
}

function skipSubscription() {
  return () => undefined;
}

export function useUnitsProvider(
  unitsProvider?: UnitsProvider,
): UnitsProvider {
  return React.useSyncExternalStore(
    unitsProvider ? skipSubscription : subscribeToUnitsProvider,
    () => unitsProvider ?? IModelApp.quantityFormatter.unitsProvider,
  );
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh, I used useSyncExternalStore in a new stratakit impl of this UI 😄 But yes you made a good point, i've aligned to your suggestion

hl662 added 2 commits October 9, 2026 11:20
…ternalStore for the default units provider

- startup shares one in-flight initialization and allows a retry after failure; drop the generation guard and
  late-registration cleanup
- useUnitsProvider reads the app provider through useSyncExternalStore, so removing an explicit provider never
  renders a stale default
@hl662

hl662 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Why should they be exported?

They were already part of the public signatures (FormatSelector: React.FC, etc.), but consumers couldn’t import them by name. API Extractor listed them as non-exported. So exporting them matches property-grid, which does exports its component props types. I could revert it, but I figured it could help?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants