Skip to content

console: Fix second loading spinner on the cluster list - #39875

Merged
leedqin merged 1 commit into
MaterializeInc:mainfrom
leedqin:console-cluster-list-double-spinner
Oct 19, 2026
Merged

leedqin merged 1 commit into
MaterializeInc:mainfrom
leedqin:console-cluster-list-double-spinner

Conversation

@leedqin

@leedqin leedqin commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Closes: CNS-192

On a cold load the cluster list shows its loading spinner and renders the rows once the clusters SUBSCRIBE snapshot arrives. It then swaps them for a second spinner before the table finally shows. Both the classic table and the per-replica preview table are affected.

Each row's overflow menu renders its items while the menu is closed. For clusters the user owns that are managed and not system clusters, that includes AlterClusterMenuItem, which read the cluster sizes through useAvailableClusterSizes, a useSuspenseQuery. On a cold cache the first render of the rows suspends, and React hides them behind the ClustersListPage Suspense fallback until the sizes return.

Before #38014 the list itself loaded through a suspending query, so both waits sat behind one fallback. Since the list moved to the subscribe-fed atom with its own loading state, they show up as two separate spinners.

Description

  • useAvailableClusterSizesWithoutSuspense reads the same query, with the same key and fetcher, without suspending. AlterClusterMenuItem uses it.
  • "Alter cluster" is disabled until the sizes load. Without that, a click during loading closes the menu with no feedback, and the modal opens later on its own once the sizes arrive.
  • useAvailableClusterSizes keeps suspending for NewClusterForm and NewReplicaModal. Switching NewClusterForm would break its default size: the size <select> is uncontrolled with a defaultValue, so options that arrive after mount leave the first size selected instead of the default.
  • Behavior change on error: a failed sizes query used to throw to the page's error boundary. It now leaves "Alter cluster" disabled and the rest of the page usable.
  • The cluster detail header mounts the same menu item, so it no longer suspends there either.

Not in this PR:

  • NewReplicaModal is mounted while closed on the cluster Replicas tab and still suspends on the sizes. That serializes the sizes request after the replicas query.
  • OverflowMenu mounts every item's hooks while the menu is closed. Making it lazy would remove the per-row fetches entirely, but it changes every overflow menu in the console.

Verification

New tests in ClustersList.test.tsx hold the sizes request open and check that the per-replica rows and the classic table's rows still render, and that "Alter cluster" is disabled until the sizes load and enabled after. The loading tests fail against the previous AlterClusterMenuItem.

I also reproduced the bug with a temporary harness, not committed, that renders the real route tree and subscribe stack against a mocked SUBSCRIBE WebSocket. Before: spinner, snapshot, second spinner with the table hidden, table. After: spinner, snapshot, table. Not yet checked in a browser against production.

🤖 Generated with Claude Code

@leedqin
leedqin requested a review from a team as a code owner October 15, 2026 18:24

/** `useAvailableClusterSizes`, with `data` undefined while loading instead of suspending. */
export function useAvailableClusterSizesWithoutSuspense() {
return useQuery(availableClusterSizesQuery());

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.

The 2 flavors of this hook feel unnecessary. They're both wrappers around 1-line calls to another hook. It seems like the calling locations could just call useQuery(availableClusterSizesQuery()); or useSuspenseQuery(availableClusterSizesQuery()); and we'd save ourselves 1 layer of complexity. Unless there's something I'm not seeing, of course.

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.

Agreed, removed both in 64f5bb6: the call sites now use useQuery(availableClusterSizesQuery()) or useSuspenseQuery(availableClusterSizesQuery()) directly.

<>
<MenuItem
// The modal needs the sizes. A click before they load would otherwise
// open it later, unprompted.

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.

I don't know if this comment is necessary.

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.

Removed.

On a cold load the cluster list showed its spinner, rendered the rows once
the clusters SUBSCRIBE snapshot arrived, and then replaced them with a
second spinner. Each row's closed overflow menu mounts AlterClusterMenuItem,
whose cluster sizes query was a useSuspenseQuery, so the first render of the
rows suspended to the page's Suspense fallback until the sizes returned.

The menu item now reads the sizes with useQuery on a shared
availableClusterSizesQuery and stays disabled until they load. The new
cluster form and the new replica modal still read them with
useSuspenseQuery.

Closes: CNS-192
@leedqin
leedqin force-pushed the console-cluster-list-double-spinner branch from 466baa3 to 64f5bb6 Compare October 19, 2026 17:33
@leedqin
leedqin merged commit 60cb5f6 into MaterializeInc:main Oct 19, 2026
85 checks passed
leedqin added a commit that referenced this pull request Nov 2, 2026
…page (#39992)

### Motivation

Closes: [CNS-204](https://linear.app/materializeinc/issue/CNS-204)

Seventh PR in the cluster details redesign stack, on top of #39889. The
redesigned cluster page kept the classic page's Replicas tab as a
stopgap, and its only way to edit or drop the cluster was the
breadcrumb's overflow menu. This moves a cluster's configuration into a
drawer opened from the page header, in the same format as the Connect
drawer, and drops the Replicas tab.

### Description

**Opening it.** A gear button next to the cluster's title, after its
size and replica pills, opens **Configure ‹cluster›**.
- The open section is the `configure` search parameter (`overview`,
`replicas` or `auto-scaling`), so it survives a reload and can be
linked.
- `useSyncObjectToSearchParams`, which lists such as the Objects tab's
Sources and Sinks use to put their sort in the URL, now keeps search
parameters it didn't write, so `configure` survives on every tab. It
still removes a key it wrote once its object drops it, such as a cleared
filter.
- The classic Replicas tab's URL, `…/replicas`, redirects to
`?configure=replicas`.
- A rename from Edit keeps the parameter, so the drawer stays open on
the renamed cluster.
- On the redesigned page, the breadcrumb no longer shows its Alter/Drop
overflow menu, so the drawer is the one place to edit or drop the
cluster. The classic page is unchanged.
- The gear and the drawer each look the cluster up in their own
component, so another cluster's status updates don't re-render the page.

**Layout.** It's a 640px side drawer, like the Connect drawer, with a
row of `ConnectMethodCard`s that switch the panel below:

- **Overview**, subtitled with the size, or "Unmanaged".
- Shows name, size, management, replication factor, owner and creation
time.
- While a resize is in progress, an info alert names its target size,
and its target replication factor when that changes too. `mz_clusters`,
and so the details, keep the current values until it finishes.
- **Edit** opens the existing Alter cluster modal for managed user
clusters. Like the cluster list's Alter cluster item since #39875, it
reads the sizes without suspending. It stays disabled until the sizes
load. The Alter cluster modal now starts its Replicas field at the
cluster's replication factor, which the app-wide clusters subscribe now
carries. It used to start at the replica count, which also counts a
hydration burst's or a graceful resize's extra replicas, so saving any
edit during one kept them. It also sends the size and replication factor
only when they change, and leaves out the SET when neither does. While a
resize runs, `mz_clusters` reports the realized size and factor, and
setting both back to them cancels the resize, so saving a rename used to
cancel it. Both fix the classic page's Alter cluster item too.
- Owners of a user cluster also get a **Drop cluster** danger zone,
using the existing drop confirmation.
- **Replicas**, subtitled "N online" or "N of M online".
- A UniversalTable of each replica's name, size, status and creation
time. An offline replica's pill carries its reason.
- Unmanaged clusters get **New replica** and a per-replica drop for
owners, through the existing modals. Managed clusters explain that they
run one replica per unit of replication factor, counted from the factor
so a running burst replica isn't included, since the server rejects
dropping one of their replicas. Only user clusters are pointed at Edit.
- **Auto-scaling**, subtitled "Enabled" or "Off", shown only from
v26.34.0, when `mz_internal.mz_cluster_auto_scaling_strategies` first
shipped.
  - Shows the burst size for hydration and the linger duration.
- A note while a burst replica is running, including after its policy
was removed and the strategy reads as null.
- Copyable `ALTER CLUSTER … SET (AUTO SCALING STRATEGY = …)` and `RESET`
statements to change or remove the policy, or the statement to turn one
on. The linger is written as whole seconds where it is one, and in
milliseconds otherwise, so copying the statement keeps a 0s or 1.5s
linger. System clusters get none, since they can't be altered.
  - Unmanaged clusters are told autoscaling needs a managed cluster.

**Data.** The cluster list already carries name, size, management, the
replication factor (added to its subscribe here), owner id and replica
statuses, and `useOwners` already has every role's name. Everything else
comes from one `executeSqlV2` request when the drawer opens:
- creation times, from `mz_object_lifetimes`;
- the autoscaling strategy, by cluster id;
- an in-progress reconfiguration's target size and replication factor,
from `mz_cluster_reconfigurations`, which shipped with the autoscaling
relation in v26.34.0.

The request reads at strict serializable, the session default. A
serializable read can come back from before a write that just committed,
and since the drawer doesn't poll, a replica added or a policy altered
moments earlier could stay missing until the drawer reopened.

Cluster and replica ids share the `u<N>` form but come from separate
counters, so a cluster's id can also be a replica's.
`buildObjectCreationTimesQuery` now takes the object types to read and
returns each row's `object_type`, and the drawer matches creation times
on type and id.

When the replica set changes, for example a resize or a burst replica
coming and going, the request's key changes. The drawer keeps showing
the previous answer for the same cluster until the new one arrives,
rather than blanking the overview.

`mz_cluster_auto_scaling_strategies` and `mz_cluster_reconfigurations`
are added to `types/materialize.d.ts` by hand, matching the generated
shape, with the file's TODO for hand-added relations.

The owners, the replica limit and, where Edit can show, the sizes are
read once by the drawer and passed to its sections, so switching
sections makes no requests.

`buildObjectCreationTimesQuery` requires its object types, and the
earlier caller passes its two explicitly.

**Also:** a `SettingsIcon` in the icon set, and an `ownerName` lookup on
`useOwners`.

### Verification

- `clusterConfigurationModel.test.ts`:
- the strategy JSON's hydration size, linger and in-flight burst, no
policy, and a burst that outlasts a removed policy;
  - a replica is online only when every process is;
- the generated statements quote the cluster and write the linger in
seconds, or in milliseconds when it isn't a whole number of seconds,
including 0;
  - a size with a quote is escaped;
- the resize note names the target size, and the replication factor when
it changes too, and says nothing when neither changes.
- `ClusterConfigurationDrawer.test.tsx`, with creation-time fixtures
where each id is also an object of the other type:
- the overview shows the owner, the replication factor, the cluster's
own creation time, Edit and Drop;
- an in-progress resize's target size shows in an alert on the overview;
- Edit is disabled while the sizes load, then opens the Alter cluster
modal, filled from the replication factor even while a burst replica
runs;
- switching sections back and forth reads the owners, the replica limit
and the sizes once;
- the Replicas text counts the replication factor rather than a burst
replica, falls back when the factor can't be read, and doesn't point a
system cluster at Edit;
  - an unmanaged cluster's owner can drop a replica from its row;
  - a zero linger shows and copies as 0s;
- a non-owner gets no Drop, and a system cluster gets no Edit, Drop or
statements;
- the Replicas card switches to the replica table with each replica's
own creation time;
- an unmanaged cluster's owner gets New replica, and its auto-scaling
section says it needs a managed cluster;
- a configured policy, an unset one, and a running burst after the
policy is removed;
  - a failed read shows an error and keeps what the cluster list has;
- an environment before v26.34.0 has no auto-scaling card and opens a
link to it on the overview.
- `ClusterDetail.test.tsx`:
  - the redesigned page has no Replicas tab and has the gear;
  - the gear opens the drawer on the overview;
  - the classic Replicas tab's URL opens it on the replicas;
- the gear opens it over the Sources list, whose real sort hook rewrites
the query string.
- `useSyncObjectToSearchParams.test.ts`: a search parameter the hook
didn't write survives, and one it wrote goes once its object drops it.
- `queries.test.ts`: `useOwners` names an owner by its role id.
- `clusterConfiguration.test.sql.ts`, against a real Materialize:
creation times told apart for a cluster and a replica that share an id,
and autoscaling policies, with and without a linger, parsed as the
drawer parses them, and no resize target once a resize settles or for a
cluster never resized.
- `clusterList.test.sql.ts` reads a managed cluster's replication factor
and none for an unmanaged one, and the clusters subscribe tests expect
the new column.
- `AlterClusterModal.test.tsx`: the Replicas field starts at the
replication factor, not a running burst's replica count; a rename sends
only the RENAME; a replication factor change sends no SIZE.

🤖 Generated with [Claude Code](https://claude.com/claude-code)




<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/materializeinc/materialize/pull/39992"
target="_blank"><picture><source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img
src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4"
alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->
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.

2 participants