perf(datasource-editor): fix typing lag and scrambling in form fields#39641
perf(datasource-editor): fix typing lag and scrambling in form fields#39641mikebridge wants to merge 8 commits intoapache:masterfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #39641 +/- ##
=======================================
Coverage 64.49% 64.50%
=======================================
Files 2566 2566
Lines 133972 134019 +47
Branches 31124 31136 +12
=======================================
+ Hits 86403 86445 +42
- Misses 46074 46079 +5
Partials 1495 1495
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
81480b7 to
a1fd13c
Compare
✅ Deploy Preview for superset-docs-preview ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
| const allColumns = [...databaseColumns, ...calculatedColumns]; | ||
| // Cached getters for derived arrays. Each returns a stable reference while | ||
| // its inputs' identities are unchanged, so downstream PureComponent / | ||
| // React.memo children stop re-rendering on unrelated state changes. |
There was a problem hiding this comment.
In other words, each keystroke is causing the metric to be recalculated and then this rerenders the tabs, so we're using a stable metrics reference here, not a value.
|
Yes, that's correct. The code adds caching for derived arrays like sorted metrics, returning a stable reference when inputs haven't changed, preventing unnecessary recalculations on each keystroke. The debounced validation also defers heavy checks to avoid blocking the UI. |
| const { datasource, metricSearchTerm } = this.state; | ||
| const { metrics } = datasource; | ||
| const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : []; | ||
| const sortedMetrics = this.getSortedMetrics(metrics); |
There was a problem hiding this comment.
Note: An empty list causes this reference to change, but sortMetrics happens to work ok on non-empty arrays because sort mutates the array and keeps the same reference. getSortedMetrics makes both cases stable.
| key={name} | ||
| {...editorProps} | ||
| onChange={this.handleChange.bind(this)} | ||
| onChange={this.handleChange} |
There was a problem hiding this comment.
handleChange is now an arrow function, so it doesn't need bind, and therefore doesn't create a new function reference each time.
| } | ||
|
|
||
| handleChange(value: string | { target: { value: string } }) { | ||
| handleChange = (value: string | { target: { value: string } }) => { |
There was a problem hiding this comment.
Changing this to an arrow function means we don't need bind (see below)
| <DatasourceContainer data-test="datasource-editor"> | ||
| <DatasourceContainer | ||
| data-test="datasource-editor" | ||
| onBlur={this.flushValidation} |
There was a problem hiding this comment.
Before, validation happened with every keystroke, now input is debounced so it will happen on blur.
| type: t('<no type>'), | ||
| config: null, | ||
| })} | ||
| itemGenerator={this.newSpatialItem} |
There was a problem hiding this comment.
I prefer to make this a stable reference, even though it isn't strictly necessary. Eventually we do this for the other unstable references (onDatasourcePropChange.bind, etc.) and clean up the whole render, but that's a bit more work outside this ticket.
| } | ||
|
|
||
| // Get datetime-compatible columns for the default datetime dropdown | ||
| const datetimeColumns = allColumns |
There was a problem hiding this comment.
The string and date columns here were always creating a new reference via filter / map.
| return output; | ||
| } | ||
|
|
||
| newSpatialItem = () => ({ |
There was a problem hiding this comment.
These are extracted into stable arrow-function references from inside the render
| data-test="edit-dataset-tabs" | ||
| onChange={this.handleTabSelect} | ||
| defaultActiveKey={activeTabKey} | ||
| destroyInactiveTabPane |
There was a problem hiding this comment.
We are switching to only mounting one tab at at a time. The destroyInactiveTabPane changes the strategy to "lazy mount + unmount on hide". This is fine because the data is untouched, but it does mean that we have to be aware that we will lose state in the tabs.
Code reviewFound 4 issues:
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
@kgabryje I think 2 & 3 can be done easily, and probably we can revert
|
a1fd13c to
9cbb04c
Compare
Three fixes from kgabryje's review on PR apache#39641: 1. TextAreaControl IME guard double-fire on Firefox/Edge. Per W3C uievents apache#202, Firefox/Edge fire a synthetic onChange after compositionend with the committed value. Add a one-shot `justComposed` flag, cleared on the next change, so the committed value isn't propagated twice on those browsers. 2. DatasourceEditor onBlur defeated debounce for intra-form navigation. React onBlur bubbles, so tabbing between fields inside the container fired the synchronous flush on every focus change. Guard with `e.relatedTarget` so flushValidation only fires when focus actually leaves the container. 3. Drop `destroyInactiveTabPane`. The lazy-mount benefit isn't worth the UX regression of losing CollectionTable local state (search term, pagination, expanded rows) on every tab switch. The remaining fixes (debounce, memoised derived arrays, stable handler identity, IME guards) carry the perf wins. The .cancel()-vs-.flush() review point requires a deeper refactor of validate() to expose a sync compute path that doesn't depend on the component's setState. Deferred to a follow-up commit. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kgabryje Here's the summary from claude on 2, 3 & 4. I will look at the first one separately:
|
Address kgabryje's review point apache#1 on PR apache#39641: componentWillUnmount was calling .cancel() on the debounced validator, which silently dropped any in-flight state propagation if the user typed within the 300 ms window before unmount. The naïve .flush() swap doesn't work either: the debounced function calls validate(this.onChange), which writes errors via setState({errors}, callback). React no-ops setState during unmount, so the callback (which calls props.onChange to propagate to the parent) never fires. Fix: split the validate code into a pure computeErrors(): string[] method and a validate(callback) wrapper. Add an optional errorsOverride parameter to onChange. In componentWillUnmount, if the validationPending flag is set, compute errors synchronously and call this.onChange(errors) directly — bypassing the dead-end setState({errors}) entirely. A manual validationPending flag tracks pending state because @types/lodash's DebouncedFunc<> doesn't expose lodash 4.17+'s .pending() method. Scope limit: the drain helps only when the parent outlives the child (route changes, AsyncEsmComponent swaps). When the DatasourceModal itself unmounts simultaneously, the parent's useState setters are also no-ops, so the propagation has nowhere to land. Closing that race fully would require lifting editor state out of React component-local state — out of scope for this PR. Documented in spec/plan/research. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from kgabryje's review on PR apache#39641: 1. TextAreaControl IME guard double-fire on Firefox/Edge. Per W3C uievents apache#202, Firefox/Edge fire a synthetic onChange after compositionend with the committed value. Add a one-shot `justComposed` flag, cleared on the next change, so the committed value isn't propagated twice on those browsers. 2. DatasourceEditor onBlur defeated debounce for intra-form navigation. React onBlur bubbles, so tabbing between fields inside the container fired the synchronous flush on every focus change. Guard with `e.relatedTarget` so flushValidation only fires when focus actually leaves the container. 3. Drop `destroyInactiveTabPane`. The lazy-mount benefit isn't worth the UX regression of losing CollectionTable local state (search term, pagination, expanded rows) on every tab switch. The remaining fixes (debounce, memoised derived arrays, stable handler identity, IME guards) carry the perf wins. The .cancel()-vs-.flush() review point requires a deeper refactor of validate() to expose a sync compute path that doesn't depend on the component's setState. Deferred to a follow-up commit. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address kgabryje's review point apache#1 on PR apache#39641: componentWillUnmount was calling .cancel() on the debounced validator, which silently dropped any in-flight state propagation if the user typed within the 300 ms window before unmount. The naïve .flush() swap doesn't work either: the debounced function calls validate(this.onChange), which writes errors via setState({errors}, callback). React no-ops setState during unmount, so the callback (which calls props.onChange to propagate to the parent) never fires. Fix: split the validate code into a pure computeErrors(): string[] method and a validate(callback) wrapper. Add an optional errorsOverride parameter to onChange. In componentWillUnmount, if the validationPending flag is set, compute errors synchronously and call this.onChange(errors) directly — bypassing the dead-end setState({errors}) entirely. A manual validationPending flag tracks pending state because @types/lodash's DebouncedFunc<> doesn't expose lodash 4.17+'s .pending() method. Scope limit: the drain helps only when the parent outlives the child (route changes, AsyncEsmComponent swaps). When the DatasourceModal itself unmounts simultaneously, the parent's useState setters are also no-ops, so the propagation has nowhere to land. Closing that race fully would require lifting editor state out of React component-local state — out of scope for this PR. Documented in spec/plan/research. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cf6e58e to
c39ab18
Compare
Three fixes from kgabryje's review on PR apache#39641: 1. TextAreaControl IME guard double-fire on Firefox/Edge. Per W3C uievents apache#202, Firefox/Edge fire a synthetic onChange after compositionend with the committed value. Add a one-shot `justComposed` flag, cleared on the next change, so the committed value isn't propagated twice on those browsers. 2. DatasourceEditor onBlur defeated debounce for intra-form navigation. React onBlur bubbles, so tabbing between fields inside the container fired the synchronous flush on every focus change. Guard with `e.relatedTarget` so flushValidation only fires when focus actually leaves the container. 3. Drop `destroyInactiveTabPane`. The lazy-mount benefit isn't worth the UX regression of losing CollectionTable local state (search term, pagination, expanded rows) on every tab switch. The remaining fixes (debounce, memoised derived arrays, stable handler identity, IME guards) carry the perf wins. The .cancel()-vs-.flush() review point requires a deeper refactor of validate() to expose a sync compute path that doesn't depend on the component's setState. Deferred to a follow-up commit. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address kgabryje's review point apache#1 on PR apache#39641: componentWillUnmount was calling .cancel() on the debounced validator, which silently dropped any in-flight state propagation if the user typed within the 300 ms window before unmount. The naïve .flush() swap doesn't work either: the debounced function calls validate(this.onChange), which writes errors via setState({errors}, callback). React no-ops setState during unmount, so the callback (which calls props.onChange to propagate to the parent) never fires. Fix: split the validate code into a pure computeErrors(): string[] method and a validate(callback) wrapper. Add an optional errorsOverride parameter to onChange. In componentWillUnmount, if the validationPending flag is set, compute errors synchronously and call this.onChange(errors) directly — bypassing the dead-end setState({errors}) entirely. A manual validationPending flag tracks pending state because @types/lodash's DebouncedFunc<> doesn't expose lodash 4.17+'s .pending() method. Scope limit: the drain helps only when the parent outlives the child (route changes, AsyncEsmComponent swaps). When the DatasourceModal itself unmounts simultaneously, the parent's useState setters are also no-ops, so the propagation has nowhere to land. Closing that race fully would require lifting editor state out of React component-local state — out of scope for this PR. Documented in spec/plan/research. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3d23fc5 to
2da59e8
Compare
Typing into any Dataset Editor text field (Settings → Basic Description,
column descriptions, metric labels, SQL expressions) was visibly slow on
realistic datasets because every keystroke re-rendered all seven tabs'
contents and ran synchronous O(n) validation inside the setState
callback. On fast typing or East-Asian IME input, characters landed in
the DOM in the order renders finished rather than the order typed,
manifesting as "scrambled" input. See sc-104866 for the full diagnosis.
Four coordinated changes in two files:
1. Debounce validate + flush on blur. Validation (duplicate-name checks,
currency format, folder consistency) is moved off the keystroke hot
path via lodash debounce (300 ms window). A new flushValidation method
is exposed as onBlur on the DatasourceContainer wrapper so focus
leaving any field flushes pending validation synchronously, keeping
the Save-button error gate accurate (errors.length > 0 disables save).
componentWillUnmount cancels the pending invocation to avoid
setState-on-unmounted warnings.
2. Lazy-mount hidden tabs via destroyInactiveTabPane so only the visible
tab's children are mounted. Eliminates the largest source of
per-keystroke render cost on wide datasets where ColumnCollectionTable
and MetricsTab would otherwise re-render on every setState.
3. Memoise derived arrays (sortedMetrics, datetimeColumns, stringColumns)
with one-pair (lastInput, lastOutput) caches so downstream
PureComponent / React.memo children stop re-rendering on unrelated
state changes. Also hoist three inline itemGenerator={() => ({...})}
lambdas to stable class-field arrow functions (newSpatialItem,
newMetricItem, newCalculatedColumnItem).
4. IME composition guards in TextAreaControl. Track isComposing state via
onCompositionStart / onCompositionEnd, skip handleChange while
composing, propagate once on compositionend. Reduces per-IME-word
work from N candidate events to one commit. Also converts handleChange
to an arrow class field so onChange={this.handleChange} has stable
identity across renders (no more .bind(this) in render).
Validation semantics are preserved (FR-008): same validators, same error
messages, same Save-button gate — only the schedule changes.
Existing 34 Jest tests across DatasourceEditor, DatasourceEditorCurrency,
and TextAreaControl pass unmodified. Playwright coverage for SC-001,
SC-003, and SC-005 deferred to a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from kgabryje's review on PR apache#39641: 1. TextAreaControl IME guard double-fire on Firefox/Edge. Per W3C uievents apache#202, Firefox/Edge fire a synthetic onChange after compositionend with the committed value. Add a one-shot `justComposed` flag, cleared on the next change, so the committed value isn't propagated twice on those browsers. 2. DatasourceEditor onBlur defeated debounce for intra-form navigation. React onBlur bubbles, so tabbing between fields inside the container fired the synchronous flush on every focus change. Guard with `e.relatedTarget` so flushValidation only fires when focus actually leaves the container. 3. Drop `destroyInactiveTabPane`. The lazy-mount benefit isn't worth the UX regression of losing CollectionTable local state (search term, pagination, expanded rows) on every tab switch. The remaining fixes (debounce, memoised derived arrays, stable handler identity, IME guards) carry the perf wins. The .cancel()-vs-.flush() review point requires a deeper refactor of validate() to expose a sync compute path that doesn't depend on the component's setState. Deferred to a follow-up commit. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address kgabryje's review point apache#1 on PR apache#39641: componentWillUnmount was calling .cancel() on the debounced validator, which silently dropped any in-flight state propagation if the user typed within the 300 ms window before unmount. The naïve .flush() swap doesn't work either: the debounced function calls validate(this.onChange), which writes errors via setState({errors}, callback). React no-ops setState during unmount, so the callback (which calls props.onChange to propagate to the parent) never fires. Fix: split the validate code into a pure computeErrors(): string[] method and a validate(callback) wrapper. Add an optional errorsOverride parameter to onChange. In componentWillUnmount, if the validationPending flag is set, compute errors synchronously and call this.onChange(errors) directly — bypassing the dead-end setState({errors}) entirely. A manual validationPending flag tracks pending state because @types/lodash's DebouncedFunc<> doesn't expose lodash 4.17+'s .pending() method. Scope limit: the drain helps only when the parent outlives the child (route changes, AsyncEsmComponent swaps). When the DatasourceModal itself unmounts simultaneously, the parent's useState setters are also no-ops, so the propagation has nowhere to land. Closing that race fully would require lifting editor state out of React component-local state — out of scope for this PR. Documented in spec/plan/research. Tests: 51/51 across 6 suites pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The IME guard added in earlier commits never engaged. Every TextAreaControl in the codebase passes language= (markdown / sql / json), which routes through Ace, and Ace handles composition internally without firing React's onCompositionStart / onCompositionEnd. Profile traces with the guard in place vs. master showed identical commit fanout and per-commit duration during IME composition on dataset-editor fields. A future PR can wire composition handlers into the Ace branch via editor.session DOM listeners — that's a different code path and warrants its own design and tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Jest/RTL test that locks in the contract documented in spec.md FR-004a: when the editor unmounts while a debounced validation is still pending, props.onChange must be called synchronously with the freshly-computed errors. Without the drain (just .cancel(), or naive .flush() that hits the dead-end setState-during-unmount path), the parent never receives the latest typed state. The test uses the "Add item" button on the Calculated columns tab to trigger a synchronous chain that sets validationPending=true (no internal TextControl debounce on this path), then unmounts before the 300ms editor debounce can fire, and asserts props.onChange was called exactly once with the new datasource and an errors array. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds dataset-editor-typing.spec.ts covering FR-005 / SC-001 (typing- cadence integrity at 25/50/100 ms inter-key delays into the Default URL field) and US2 acceptance scenarios (FR-008 / SC-003): duplicate column name surfaces as error, Save is blocked while validation is pending and reveals the error, and the error clears within the debounce window once corrected. Adds two helpers: - createWideTestDataset (helpers/api/dataset.ts) creates a virtual dataset with N integer-aliased columns (default 50). Provides the realistic-size dataset SC-001 calls for without depending on a specific physical table. - createWideTestDatasetForTest (tests/dataset/dataset-test-helpers.ts) wraps it with the testAssets cleanup pattern. Tests need to be verified in CI; local Playwright run blocked by session/SECRET_KEY drift after the alembic re-stamp work earlier this session, unrelated to the test code itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Playwright spec landed in the prior commit failed CI on selector
strategy: getByLabel('Default URL') doesn't resolve (Field/TextControl
don't wire htmlFor/aria-labelledby), and the duplicate-column-name
error doesn't render in the dialog body — it surfaces as the disabled
Save button's tooltip text. Skipping the 6 tests with a comment block
documenting the required follow-up (controlId-based data-test selector
for the typing field; Save-button-state assertions for validation flow).
Also picks up a one-line prettier fix on DatasourceEditor.tsx that the
prior commit missed.
The Jest unit test for the unmount-drain path (T031b) covers the most
subtle behavioural contract; the existing 26 Jest tests cover the
synchronous validation semantics. End-to-end Playwright coverage is
desirable but doesn't block the perf fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the Playwright spec and unused fixture helpers added in e111c5c / b346b27. The first CI run revealed: - getByLabel('Default URL') doesn't resolve because Field/TextControl don't wire htmlFor/aria-labelledby on the input — the label renders as a separate FormLabel header element above the input. - The duplicate-column-name error doesn't render in the dialog body; it surfaces only as the disabled Save button's tooltip text per DatasourceModal:355-370. Fixing both selector strategies plus iterating through CI cycles isn't worth blocking the perf fix on. The unit test for the unmount-drain path (T031b in 73e2ab9) covers the most subtle behavioural contract, and the existing 26 Jest tests cover the synchronous validation semantics. End-to-end Playwright coverage for typing cadence and validation flow is desirable but deferred to a separate PR. Reverts: - superset-frontend/playwright/helpers/api/dataset.ts (createWideTestDataset) - superset-frontend/playwright/tests/dataset/dataset-test-helpers.ts (createWideTestDatasetForTest) - superset-frontend/playwright/tests/dataset/dataset-editor-typing.spec.ts (deleted) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2da59e8 to
5a27207
Compare
🚧🚧🚧 Still draft, but actively iterating on review feedback. Three rounds of fixes have landed, plus a scope-narrowing follow-up. 🚧🚧🚧
SUMMARY
Typing into any Dataset Editor text field (Settings → Basic Description, column descriptions, metric labels, SQL expressions) was visibly slow on real-world datasets. On fast typing, characters landed in the DOM in the order renders finished rather than the order typed, manifesting as "scrambled" input. No network I/O is involved — the bug is a React render-storm: every keystroke ran synchronous O(n) validation (duplicate checks, currency Intl.NumberFormat instantiation, folder validation) inside the
setStatecallback, propagating through children whose props had unstable identity on every render.Two coordinated changes land in two files (
DatasourceEditor.tsx,Fieldset/index.tsx); no new npm dependencies (lodash/debounceis already transitively pinned).Debounce validate with a guarded blur-flush and a synchronous unmount drain. Validation moves off the keystroke hot path via
lodash.debounce(..., 300). AhandleContainerBlurhandler onDatasourceContainerflushes pending validation when focus actually leaves the container —e.relatedTargetis checked so intra-form tabbing between fields does not flush, preserving the debounce for the common multi-field-edit case.componentWillUnmountdoes a drain-then-cancel: if a validation is pending, errors are computed synchronously via a new purecomputeErrors()method and propagated to the parent viaprops.onChange(newDatasource, errors)directly — bypassing the dead-endsetState({errors})(a no-op during unmount). Save-path safety is preserved because the parentDatasourceModalalready disables Save whenerrors.length > 0, and the blur-flush ensureserrorsreflects the latest typed state by the time the Save button is reachable.Memoise derived arrays (
sortedMetrics,datetimeColumns,stringColumns) with one-pair (lastInput, lastOutput) caches so downstreamPureComponent/React.memochildren stop re-rendering on unrelated state changes. Hoist three inlineitemGenerator={() => ({...})}lambdas to stable class-field arrow functions (newSpatialItem,newMetricItem,newCalculatedColumnItem).Validation semantics are preserved — same validators, same error messages, same Save-button gate. Only the schedule and the propagation lifecycle change.
What was tried and reverted
destroyInactiveTabPane=true(lazy-mount hidden tabs): originally shipped; reverted after review feedback. Per @kgabryje's review, the perf gain was outweighed by UX regressions — losingCollectionTablelocal state (search, pagination, expanded rows, sort) on every tab switch, plus losing pending debounced edits inside expandedTextAreaControlrows. The remaining changes (debounce + memoised arrays + stable handlers) carry the typing-lag fix on their own; hidden tabs still get reconciled per keystroke but theirPureComponent-wrapped children short-circuit on stable props.IME composition guards in
TextAreaControl(isComposing/justComposedflags): originally shipped to suppress per-candidate-character renders during East-Asian composition; dropped from this PR after profiling showed the guard never engages. EveryTextAreaControlcallsite in the codebase passeslanguage=(markdown / sql / json), which routes through Ace'sTextAreaEditor. Ace handles composition internally and bypasses React'sonCompositionStart/onCompositionEnd, so the guard's flags never flipped. Profile traces with the guard in place vs. master showed identical commit fanout and per-commit duration during IME composition on the dataset Description field.Wiring composition handlers into the Ace branch (via
editor.sessionDOM listeners or similar) is a separate effort and a separate PR. The fast-typing fix in this PR carries over to IME composition opportunistically — memoisation and debouncing make per-keystroke work cheaper regardless of whether the keystroke comes from a regular keypress or an IME candidate-character event. So IME users see the general perf win, just not a targeted "only one validate per composed word" behaviour.Review history (PR #39641 review by @kgabryje)
Four review points; three landed, one architectural limit explicitly documented:
componentWillUnmountcalling.cancel()silently dropped pending state propagationvalidate()intocomputeErrors()+setStatewrite; on unmount, compute synchronously and callprops.onChangedirectly. Caveat: this helps when the parent outlives the child (route changes, AsyncEsmComponent swaps); when the parent unmounts simultaneously (the typical "close modal" race), itsuseStatesetters are also no-ops, so the propagation has nowhere to land. Closing that fully would require lifting editor state out of React component-local state — an architectural change beyond this PR's scope.justComposedflag, then the entire IME guard was dropped after investigation showed it never engaged in the dataset editor. See "What was tried and reverted" above.onBlurbubbling defeats the debounce on intra-form focus changese.relatedTargetguard inhandleContainerBlur(described above).destroyInactiveTabPaneis too aggressiveBEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
Chrome React Profiler recordings on
wb_health_population(Settings → Basic Description, ~40 chars typed at normal speed). Both recordings are on the currently-shipped code (master vs the fix branch); cache-bust was verified via a temporary tab-label change so the bundle freshness on each side is unambiguous.The "worst commit got slightly slower" is the expected debounce signature: many small per-keystroke renders are traded for fewer larger settle renders. The point is keeping per-keystroke work off the critical path so the frame budget is met during typing — not minimising the slowest commit.
BEFORE
AFTER
TESTING INSTRUCTIONS
wb_health_population— ~50 columns).DatasourceEditorpath.destroyInactiveTabPane): in the Columns tab, expand a row, set a sort, type in the search filter. Switch to Metrics, then back to Columns. Expected: search term, sort, and expansion are all preserved.Jest test suite (51 tests across 6 suites) passes unchanged:
Design decisions captured in the spec
relatedTargetguard) was chosen over a cross-component ref-flush via theAsyncEsmComponentwrapper. Simpler, no ref plumbing, and the two-step Save confirm modal naturally produces enough delay for the blur-fired flush to settle errors before the user can click OK.DatasourceEditorkept as a class component (not migrated to functional). Explicitly justified — the bug is fixable surgically; a functional rewrite is out of scope per spec.md.TextAreaControlhere useslanguage=and routes through Ace, bypassing React composition events). That's a separate effort with its own design + cross-browser tests. Documented in research.md R6.What's missing (why this is a draft)
dataset-editor-typing.spec.tswith 25/50/100 ms keystroke speeds and the three duplicate-column-name validation scenarios — deferred to follow-up. First attempt revealed selector strategy issues (getByLabel('Default URL')doesn't resolve because Field/TextControl don't wire htmlFor/aria-labelledby; the duplicate-name error only surfaces in the disabled Save button's tooltip text perDatasourceModal:355-370, not as visible text). Reworking selectors + iterating over CI cycles wasn't worth blocking this perf fix on.playwright-tests (chromium)job — verifies that no existing Dataset Editor Playwright test regresses from these frontend changes (SC-005)destroyInactiveTabPanewas revertedADDITIONAL INFORMATION
Constitution Compliance
Per the project constitution, Principle III (Modern Frontend Patterns) requires functional components with hooks for new code. This PR edits the existing class
PureComponentDatasourceEditorrather than migrating it — justified because the bug can be resolved surgically without a functional rewrite, and the spec explicitly scopes the migration out of scope. Any new code introduced by this PR (the memoisation helpers, unmount-drain logic) could have been written functionally if not for the surrounding class context; they follow functional patterns in spirit (arrow class fields for stable identity, nothis-binding ceremony, pure helpers likecomputeErrors()that don't depend on instance lifecycle).🤖 Generated with Claude Code