Skip to content

Add onDataStateChange to ITwinGrid - #231

Merged
LukaszKokot merged 17 commits into
mainfrom
lk/itwin-grid-data-state
Sep 2, 2026
Merged

LukaszKokot merged 17 commits into
mainfrom
lk/itwin-grid-data-state

Conversation

@LukaszKokot

@LukaszKokot LukaszKokot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Partially solves AB#2114177

Why

Consumers need to know when the grid has finished answering a query. The only way that state left the grid was with postProcessCallback. And it runs during render and never says which request a status belongs to, so a consumer (like Studio) reconstructs that from its own props.
onDataStateChange reports from an effect and carries the query it belongs to, plus hasMore, since complete means the page landed rather than all the data being present, and the error, which until now only reached the logger.

Why it is split this way

The hook state was three useState calls set from one async callback, so a render could show complete beside the previous query's iTwins. Merging them into one object is what makes a report worth trusting.
It was then still written from seven scattered setFetchState calls, four of them inside the fetch effect. From each write, we created and extracted a properly named function.

The rest is placement. The state and the page request have their own homes now, the latter following createFetchIModelsFn in useIModelData.

Testing

  • Additional unit tests created
  • Manually tested in Storybook, and the callback function has been instrumented with logging so we can see how it behaves in Storybook's UI.
  • Tested in Studio: I actually went on and changed the e2e code in my local worktree-itwin-picker-steps branch (see https://github.com/iTwin/studio/pull/4089) to use the new callback (I pointed Studio's pnpm to a tarball of this branch here).
    No problem to report, no retrying steps, and a much cleaner way to infer state.

For reviewers

I suggest looking at individual commits to see how/what went into the refactor ; it's easier to follow.

The status, the iTwins and the has-more flag were three useState calls
set from the same async fetch callback. Nothing guarantees they land in
one render, so a render could show Complete beside the previous query's
iTwins.

Initial status is now fetching rather than undefined, which is what
postProcessCallback receives as its second argument on the first render.
The grid's fetching state only escaped through postProcessCallback, a
hook for transforming the iTwin array that consumers were reading a
status out of. It runs during render, so a consumer had to work out
which request a status belonged to from its own props.

The new prop reports from an effect, and carries the query it belongs
to. It also carries hasMore, since complete means the page landed
rather than all the data being present, and the error behind
error_fetchFailed, which until now only reached the logger.

The query names the subclass as well as the request type, filter text
and ordering, so that switching subclass is not mistaken for a refresh
of the previous one.

Partially solves AB#2114177
onDataStateChange matches the argTypesRegex in preview.tsx, so Storybook
infers an action spy for it in every story that does not declare one.
The grid calls the prop while it renders, which an inferred spy throws
on, taking down every story in both files.
Logs each report with the time its query took to settle, and sends the
report itself to the Actions panel. Searching the favorites or recents
tab shows the case that is otherwise hard to see: a result reported
with no fetch before it, answered from the iTwins already loaded.
Follows createFetchIModelsFn in useIModelData, which already owns URL
assembly and the fetch for the iModel grid. Takes an options object
rather than that one's ten positional parameters, and reads the query
descriptor directly, since the request type, filter text, subclass and
ordering are exactly what the URL needs.

The effect keeps everything stateful: the superseded-request guard, the
totalCount and pagination writes, the favorites reset, and the abort on
cleanup.

A totalCount of undefined now means the response carried no count.
Returning Number(null) instead would have reported zero.
The state became one object so that a render could never show one
query's status beside another query's iTwins, but it was still written
from seven scattered setFetchState calls, four of them inside the fetch
effect. The invariant held by convention.

Each write is now a named transition and setFetchState is referenced
nowhere else. Every transition takes what it needs as an argument so it
can carry empty dependencies and sit in the effect dependency arrays
without re-running them.

Three things stay with the caller on purpose: the logging, since a
transition that logs would need the logger in its dependencies; the
totalCount, pagination and favorites writes, so their order relative to
the state write is unchanged; and the page-zero check ahead of
markFetching, which is pagination rather than state.
useITwinData now derives the query, decides what to request and when,
and drives the transitions. What the grid's data is, and what a report
says about it, lives next door.

Client side filtering moves with it, because the filtered list is what
a report carries rather than something the fetch needs. The hook takes
only the query: filterText is filterOptions ?? "", and useITwinFilter
lowercases both to the same empty string.

The ref that reset reads is now declared inside the hook, which keeps
it ahead of the two reset effects that call reset, since a hook's
effects are queued where the hook is called.
searchParams.get returns null rather than an empty string, so ?? says
what is meant and clears the lint warning.
The two declared the same five fields, so a field added to the report
had to be added twice. FetchState now takes them from ITwinDataState.

They stay separate types because the iTwins differ: the state holds
every page fetched, a report carries only what client side filtering
kept. Collapsing them would leave the variable name as the only thing
saying which is which, and reporting the state directly would then
typecheck.
The payload belongs in the prop's own documentation, not the changelog.
It argued for where the code sits rather than saying anything the
signature does not.
@LukaszKokot LukaszKokot self-assigned this Aug 18, 2026
@LukaszKokot
LukaszKokot marked this pull request as ready for review August 18, 2026 22:08
@LukaszKokot
LukaszKokot force-pushed the lk/itwin-grid-data-state branch from e75fa51 to db65f8b Compare August 19, 2026 19:21
@LukaszKokot

Copy link
Copy Markdown
Contributor Author

@alexdunae @ben-polinsky could you please take a look ? I've tested this locally on my Studio branch targeting this local branch as override. I can confirm it works fine and we can remove a bunch of code from there, including defensive assertions.

Backs out the one part of this branch that changed behaviour for
consumers who never pass the new prop, since postProcessCallback
receives the status as its second argument.

Only the initial state is undefined. startingOver keeps fetching,
because it also runs on every reset and those went to fetching before.
The report skips a state with no status, so the new callback still
never reports one.
The report effect depends on the query, so a new object every render
reports the same state again on every render.
The data override and the missing token both settle in the same effect
flush as the reset, so no fetching render commits and those queries
report once, not twice.

The change file also left out the MUI grid.
@LukaszKokot
LukaszKokot requested a review from alexdunae August 25, 2026 01:25
@LukaszKokot

Copy link
Copy Markdown
Contributor Author

Hey @ben-polinsky , could you please take a look if you have some spare time 🙇🏻🙏🏻 ? It's the one brick missing for https://github.com/iTwin/studio/pull/4089 in Studio for -hopefully- reducing one of the main sources of flakiness 🙏🏻

@ben-polinsky ben-polinsky left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this part of the PR description correct? It says:

status is fetching rather than undefined on the first render

The final code does the opposite on purpose? nothingDecidedYet() preserves undefined, and the new test confirms this.

Comment thread packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts Outdated
@LukaszKokot

Copy link
Copy Markdown
Contributor Author

Is this part of the PR description correct? It says:

status is fetching rather than undefined on the first render

The final code does the opposite on purpose? nothingDecidedYet() preserves undefined, and the new test confirms this.

ah yes, after Alex' suggestion, I switched it to undefined (for good reasons) and forgot to update the PR description. I'll do it and address the nit, and we're good to go. Thanks for the review 🙇🏻‍♂️

@LukaszKokot
LukaszKokot merged commit 78b7f9a into main Sep 2, 2026
5 checks passed
@LukaszKokot
LukaszKokot deleted the lk/itwin-grid-data-state branch September 2, 2026 01:07
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