Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1253,11 +1253,6 @@
"count": 1
}
},
"packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx": {
"id-length": {
"count": 2
}
},
"packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx": {
"id-length": {
"count": 1
Expand Down
1 change: 0 additions & 1 deletion packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add a shared `EstimatedChanges` Snaps JSX component for transaction confirmations, rendering send/receive asset rows with loading, not-available, and no-changes states ([#369](https://github.com/MetaMask/internal-snaps/pull/369))
- Takes translated `labels`, display-ready `assets` (`EstimatedChangesAsset`), a `scanFetchStatus` (`EstimatedChangesFetchStatus`), and a `scanError`
- Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#374](https://github.com/MetaMask/internal-snaps/pull/374))
- Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
- Add `noopAssetHandlers`, the no-op asset entrypoints network Snaps must export to keep the `endowment:assets` permission ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
Expand Down
1 change: 0 additions & 1 deletion packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer';
export { EstimatedChanges } from './ui/EstimatedChanges/EstimatedChanges';
export type {
EstimatedChangesAsset,
EstimatedChangesFetchStatus,
EstimatedChangesLabels,
EstimatedChangesProps,
} from './ui/EstimatedChanges/EstimatedChanges';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,55 +32,39 @@ const render = (
EstimatedChanges({
assets: [],
labels,
scanFetchStatus: 'fetched',
scanError: false,
isFetching: false,
isUnavailable: false,
...props,
}),
);

describe('EstimatedChanges', () => {
it.each(['loading', 'fetching'] as const)(
'renders a skeleton while %s and nothing is seeded',
(scanFetchStatus) => {
expect(render({ scanFetchStatus })).toContain('"type":"Skeleton"');
},
);

it('renders not available on error when nothing is seeded', () => {
expect(render({ scanFetchStatus: 'error' })).toContain('Not available');
it('renders a skeleton while fetching and nothing is seeded', () => {
expect(render({ isFetching: true })).toContain('"type":"Skeleton"');
});

it('renders not available when a fetched scan reports an error', () => {
const serialized = render({ scanError: true });
it('renders not available when unavailable and nothing is seeded', () => {
const serialized = render({ isUnavailable: true });

expect(serialized).toContain('Not available');
expect(serialized).not.toContain('No changes');
});

it.each([
{ scanFetchStatus: 'loading' },
{ scanFetchStatus: 'fetching' },
{ scanFetchStatus: 'error' },
{ scanFetchStatus: 'fetched', scanError: true },
] as const)('keeps seeded rows visible for %o', (props) => {
const serialized = render({ assets: [out], ...props });
it.each([{ isFetching: true }, { isUnavailable: true }])(
'keeps seeded rows visible for %o',
(props) => {
const serialized = render({ assets: [out], ...props });

expect(serialized).toContain('-10 XLM');
expect(serialized).not.toContain('"type":"Skeleton"');
expect(serialized).not.toContain('Not available');
});
expect(serialized).toContain('-10 XLM');
expect(serialized).not.toContain('"type":"Skeleton"');
expect(serialized).not.toContain('Not available');
},
);

it('renders no changes when fetched without assets', () => {
it('renders no changes when there are no assets', () => {
expect(render({})).toContain('No changes');
});

it('renders only the header before the first fetch', () => {
const serialized = render({ scanFetchStatus: 'initial' });

expect(serialized).toContain('Estimated changes');
expect(serialized).not.toContain('No changes');
});

it('renders send and receive rows', () => {
const serialized = render({ assets: [out, inflow] });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,13 @@ export type EstimatedChangesLabels = {
noChanges: string;
};

export type EstimatedChangesFetchStatus =
| 'initial'
| 'loading'
| 'fetching'
| 'fetched'
| 'error';

export type EstimatedChangesProps = {
assets: EstimatedChangesAsset[];
labels: EstimatedChangesLabels;
scanFetchStatus: EstimatedChangesFetchStatus;
/** Whether the completed scan itself reported an error (e.g. simulation failure). */
scanError: boolean;
/** Whether the estimate is still being fetched. */
isFetching: boolean;
/** Whether the estimate could not be produced (e.g. fetch or simulation failure). */
isUnavailable: boolean;
};

const Header = ({
Expand Down Expand Up @@ -126,50 +120,53 @@ const AssetGroup = ({
* Renders the estimated balance changes of a transaction, grouped into
* "send" and "receive" rows, with loading, error and empty states.
*
* The header is always rendered. The body depends on the props:
*
* 1. No assets and `isFetching` → loading skeleton (takes precedence over
* `isUnavailable`).
* 2. No assets and `isUnavailable` → "not available" message.
* 3. No assets otherwise → "no changes" message (an empty result means the
* transaction does not change any balance).
* 4. Assets present → "send" and/or "receive" groups; a group with no assets
* is omitted. `isFetching` and `isUnavailable` are ignored in this case, as
* rows seeded locally by the caller are final.
*
* @param props - The component props.
* @param props.assets - The display-ready asset changes.
* @param props.labels - The translated labels.
* @param props.scanFetchStatus - The fetch status of the scan producing the changes.
* @param props.scanError - Whether the completed scan itself reported an error.
* @param props.isFetching - Whether the estimate is still being fetched.
* @param props.isUnavailable - Whether the estimate could not be produced.
* @returns The estimated changes section.
*/
export const EstimatedChanges = ({
assets,
labels,
scanFetchStatus,
scanError,
isFetching,
isUnavailable,
}: EstimatedChangesProps): ComponentOrElement => {
// Rows seeded locally by the caller are final, so they are never replaced by
// the loading/error states driven by the remote scan.
const hasAssets = assets.length > 0;

@stanleyyconsensys stanleyyconsensys Sep 30, 2026 •

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.

nit:

i think there are 4 cases of component

if no asset:
   if fetching:
      show fetching
   else if Unavailable:
      show isUnavailable
   else: // becoz no asset means no change
      show noChange

if above case not hit then
   show header + asset group

can we add them into the component description or some where else

as it is not easy to read from the code

// the loading/unavailable states.
if (assets.length === 0) {
if (isFetching) {
return (
<Section direction="vertical">
<Header labels={labels} />
<Box alignment="space-between" direction="horizontal">
<Skeleton width={60} />
<Skeleton width={100} />
</Box>
</Section>
);
}

if (
!hasAssets &&
(scanFetchStatus === 'loading' || scanFetchStatus === 'fetching')
) {
return (
<Section direction="vertical">
<Header labels={labels} />
<Box alignment="space-between" direction="horizontal">
<Skeleton width={60} />
<Skeleton width={100} />
</Box>
</Section>
<MessageSection
labels={labels}
message={isUnavailable ? labels.notAvailable : labels.noChanges}
/>
);
}

if (
!hasAssets &&
(scanFetchStatus === 'error' ||
(scanFetchStatus === 'fetched' && scanError))
) {
return <MessageSection labels={labels} message={labels.notAvailable} />;
}

if (scanFetchStatus === 'fetched' && !hasAssets) {
return <MessageSection labels={labels} message={labels.noChanges} />;
}

return (
<Section>
<Header labels={labels} />
Expand Down
4 changes: 4 additions & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#386](https://github.com/MetaMask/internal-snaps/pull/386))

## [1.1.0]

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/stellar-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "NPPMsrpTF/jZw4h6yS0Sy+wxr2icoIogu82ckFLEjcc=",
"shasum": "GhrGMVfrDCWuieBCmKWXY1g31zoiCMFhIyVt6RYzJ5k=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,19 @@ describe('EstimatedChanges', () => {
logo: 'https://example.com/usdc.png',
};

it('renders a skeleton while the remote scan is fetching and nothing is seeded', () => {
const component = EstimatedChanges({
changes: { assets: [] },
preferences,
scanFetchStatus: FetchStatus.Fetching,
});

expect(JSON.stringify(component)).toContain('"type":"Skeleton"');
expect(JSON.stringify(component)).not.toContain('-10 XLM');
});
it.each([FetchStatus.Initial, FetchStatus.Fetching])(
'renders a skeleton while the remote scan is %s and nothing is seeded',
(scanFetchStatus) => {
const component = EstimatedChanges({
changes: { assets: [] },
preferences,
scanFetchStatus,
});

expect(JSON.stringify(component)).toContain('"type":"Skeleton"');
expect(JSON.stringify(component)).not.toContain('-10 XLM');
},
);

it('keeps locally-seeded rows visible while the remote scan is fetching', () => {
const component = EstimatedChanges({
Expand Down
Loading
Loading