Skip to content

fix(dashboard): prevent undo crash on new dashboard opened in edit mode#41252

Open
ayushanand13 wants to merge 6 commits into
apache:masterfrom
ayushanand13:fix/dashboard-undo-new-dashboard-crash
Open

fix(dashboard): prevent undo crash on new dashboard opened in edit mode#41252
ayushanand13 wants to merge 6 commits into
apache:masterfrom
ayushanand13:fix/dashboard-undo-new-dashboard-crash

Conversation

@ayushanand13

@ayushanand13 ayushanand13 commented Jun 20, 2026

Copy link
Copy Markdown

SUMMARY

The reported crash is TypeError: Cannot read properties of undefined (reading 'type'), thrown when Undo reverts the dashboard layout to a rootless/empty state and rendering code reads component.type on a missing component.

Version note. The report is against 6.1.0. The frontend now locks redux-undo to 1.1.0, whose clearHistory() under ignoreInitialState resets _latestUnfiltered to null — so a freshly hydrated dashboard already keeps Undo disabled and the exact reproduction does not trigger on current master. This PR hardens the undo-history handling so the crash class is impossible regardless of the redux-undo version or how a rootless layout might reach the stack.

Changes (superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts):

  1. Reset undo history on every HYDRATE_DASHBOARD at the reducer level — hydration is not a user edit and must never be undoable; this guarantees the Undo control starts disabled and drops a previous dashboard's layout on SPA navigation (complementing the component-level clearDashboardHistory() from fix(dashboard): clear undo history #40569).
  2. Guard undo/redo against invalid layouts — never replace a valid present with a rootless one; the transition is rejected and the valid layout kept, leaving history intact so undoLayoutAction() doesn't misread an emptied stack as a clean, fully-reverted dashboard.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

See the screen recording in #41246.

  • Before: clicking Undo on a dashboard whose undo history contains a rootless/empty layout throws TypeError: Cannot read properties of undefined (reading 'type').
  • After: Undo is disabled until a real layout change is made, and undo/redo can never revert to an empty/invalid layout.

TESTING INSTRUCTIONS

cd superset-frontend
npm run test -- src/dashboard/reducers/undoableDashboardLayout.test.ts

Reducer unit tests (seeded against the locked redux-undo 1.1.0 behavior) cover: hydration leaves Undo disabled; a layout edit flows through the wrapped reducer; re-hydrating a different dashboard clears the previous dashboard's stack (SPA navigation); undo never reverts to a rootless layout (guard) and preserves history; and a normal valid -> valid undo still works.

Manual: create a dashboard via + Dashboard, confirm Undo is disabled before any change, then make an edit and confirm Undo reverts it without error.

ADDITIONAL INFORMATION

Opening a brand-new dashboard directly in edit mode (`?edit=true`) could
leave a stale or empty layout in the redux-undo history, so the Undo
control was enabled before any user action. Clicking it reverted the
layout to an empty state, throwing
`TypeError: Cannot read properties of undefined (reading 'type')` and
leaving the UI broken until reload.

Root cause: `HYDRATE_DASHBOARD` is a tracked action, and redux-undo's
`CLEAR_HISTORY` resets `_latestUnfiltered` to the current `present`
(non-null). When history is cleared while the layout is still the
pre-hydration `{}`, the next hydration captures that empty layout as an
undoable baseline.

Harden the undoable dashboard-layout reducer:
- Reset the undo history on every `HYDRATE_DASHBOARD` so hydration (not a
  user edit) is never undoable and no layout from a previously edited
  dashboard lingers after SPA navigation between dashboards.
- Never let undo/redo replace a valid layout with an invalid (rootless)
  one; drop the corrupt history and keep the last valid layout instead,
  so clicking Undo can never crash the dashboard.

Add reducer unit tests covering the stale-baseline scenario, SPA
navigation, the undo guard, and normal undo of a real edit.

Fixes apache#41246
@dosubot dosubot Bot added change:frontend Requires changing the frontend dashboard:editmode Related to te Dashboard edit mode labels Jun 20, 2026
} from 'src/dashboard/util/componentTypes';

// Cast reducer to accept partial mock data and inspect history fields in tests
const reducer = undoableLayoutReducer as (state: any, action: any) => any;

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.

Suggestion: Replace the reducer cast that uses any with concrete reducer-derived types (for state, action, and return) so the test keeps type safety without broad any usage. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The file is TypeScript and the cast explicitly uses any for both the reducer state and action parameters, which violates the custom rule against new or modified TS/TSX code using any. The suggestion correctly identifies a real rule violation in the current file.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts
**Line:** 37:37
**Comment:**
	*Custom Rule: Replace the reducer cast that uses `any` with concrete reducer-derived types (for state, action, and return) so the test keeps type safety without broad `any` usage.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The use of any in the reducer cast bypasses TypeScript's type checking, which is discouraged. To resolve this, you should replace any with the specific types expected by the reducer, typically defined in the dashboard layout module.

Proposed Fix

Update the reducer cast in superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts to use concrete types:

// Replace the existing cast with concrete types
import { DashboardLayoutState } from 'src/dashboard/types';
import { AnyAction } from 'redux';

const reducer = undoableLayoutReducer as (state: DashboardLayoutState | undefined, action: AnyAction) => DashboardLayoutState;

(Note: Ensure DashboardLayoutState and AnyAction are imported from your project's type definitions.)

There are no other comments on this PR to address. Would you like me to proceed with any other tasks?

superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts

const reducer = undoableLayoutReducer as (state: DashboardLayoutState | undefined, action: AnyAction) => DashboardLayoutState;

@bito-code-review bito-code-review Bot 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.

Code Review Agent Run #9a6e31

Actionable Suggestions - 1
  • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts - 1
    • Naming inconsistency with related modules · Line 21-21
Review Details
  • Files reviewed - 2 · Commit Range: 4b2138d..4b2138d
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

// eslint-disable-next-line import/named
import undoable, { StateWithHistory } from 'redux-undo';
import { UNDO_LIMIT } from '../util/constants';
import undoable, { ActionCreators, StateWithHistory } from 'redux-undo';

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.

Naming inconsistency with related modules

Rename the imported ActionCreators from 'redux-undo' to UndoActionCreators and update all references (e.g., UndoActionCreators.clearHistory()) to match the alias used elsewhere in the dashboard module.

Code Review Run #9a6e31


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Comment on lines +145 to +150
if (
state &&
isValidLayout(state.present) &&
!isValidLayout(nextState.present)
) {
return baseUndoableReducer(state, ActionCreators.clearHistory());

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.

Suggestion: Clearing history in the invalid-transition guard can incorrectly mark a dashboard as having no unsaved changes after an Undo. undoLayoutAction treats past.length === 0 as "fully reverted" and sets hasUnsavedChanges to false, but this branch empties history even when the current layout is still edited. Preserve enough history state (or signal this recovery path separately) so Undo recovery from corrupt history does not fake a clean state. [logic error]

Severity Level: Major ⚠️
- ⚠️ Dashboard header may hide unsaved-changes indicator incorrectly.
- ⚠️ Navigation away can skip UnsavedChangesModal despite edits.
- ⚠️ Users may believe edits saved when history was just cleared.
Steps of Reproduction ✅
1. In the dashboard header
(`superset-frontend/src/dashboard/components/Header/index.tsx:95-105, 215-217`), the Undo
button and Ctrl+Z shortcut call `boundActionCreators.onUndo()`, which is bound to
`undoLayoutAction` from `src/dashboard/actions/dashboardLayout.ts:12-29`.

2. Consider a dashboard editing session where `dashboardState.hasUnsavedChanges` is `true`
(set by layout edit thunks via `setUnsavedChangesAfterAction` in
`src/dashboard/actions/dashboardLayout.ts:99-127,130-205` or by header actions like
`handleOnPropertiesChange` in `Header/index.tsx:139-157`), and the `dashboardLayout` undo
history contains a corrupt last past entry whose layout is missing `DASHBOARD_ROOT_ID` (an
invalid/rootless layout the new guard is designed to protect against).

3. When the user triggers Undo, `undoLayoutAction` dispatches `UndoActionCreators.undo()`
(`src/dashboard/actions/dashboardLayout.ts:16-17`), which is handled by `undoableReducer`
in `src/dashboard/reducers/undoableDashboardLayout.ts:135-153`. `baseUndoableReducer`
first computes `nextState`; because the last past entry is invalid,
`isValidLayout(state.present)` is `true` and `!isValidLayout(nextState.present)` is
`true`, so the guard at lines 145-149 runs and returns `baseUndoableReducer(state,
ActionCreators.clearHistory())` (line 150), clearing `dashboardLayout.past` while keeping
`state.present` (the edited layout) unchanged.

4. Still inside the same Undo flow, `undoLayoutAction` then calls `getState()` and reads
`dashboardLayout` and `dashboardState` (`src/dashboard/actions/dashboardLayout.ts:19-20`).
Since the guard has just cleared history, `dashboardLayout.past.length === 0` even though
the current layout is still edited, and assuming `dashboardState.maxUndoHistoryExceeded`
and `dashboardState.updatedColorScheme` are both false (their normal values for a typical
edit session, initialized in `hydrate` at `src/dashboard/actions/hydrate.ts:26-31`), the
condition at `dashboardLayout.ts:21-25` passes and `dispatch(setUnsavedChanges(false))`
executes. As a result, `dashboardState.hasUnsavedChanges` becomes `false`, and the
header's unsaved-changes handling (`useUnsavedChangesPrompt` wired at
`Header/index.tsx:87-95`) stops warning on navigation and no longer indicates unsaved
edits, even though the user's layout changes are still present.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts
**Line:** 145:150
**Comment:**
	*Logic Error: Clearing history in the invalid-transition guard can incorrectly mark a dashboard as having no unsaved changes after an Undo. `undoLayoutAction` treats `past.length === 0` as "fully reverted" and sets `hasUnsavedChanges` to false, but this branch empties history even when the current layout is still edited. Preserve enough history state (or signal this recovery path separately) so Undo recovery from corrupt history does not fake a clean state.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@richardfogaca richardfogaca self-requested a review June 20, 2026 13:15

@richardfogaca richardfogaca 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.

Posting on Richard's behalf - this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

Left one functional concern that looks worth addressing before merge. The type-safety note is minor, but it does line up with the repo's "no new any" guidance.

Functional - worth addressing

  • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts:145-150

    The invalid-transition guard keeps state.present when undo would move to a rootless layout, but it also clears the entire undo history. undoLayoutAction() then reads the post-dispatch state and treats dashboardLayout.past.length === 0 as "fully reverted", which dispatches setUnsavedChanges(false) when maxUndoHistoryExceeded and updatedColorScheme are false.

    That means the recovery path can leave the edited layout visible while marking the dashboard clean, so the header/nav-away protection may stop warning even though there are still unsaved edits. Could we preserve enough history state, or otherwise signal this recovery path so undoLayoutAction() does not fake a clean state after dropping corrupt history?

Minor

  • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts:37

    Small type-safety gap: the test casts the reducer through (state: any, action: any) => any. Since the repo guidance explicitly avoids new any in TS, would it be worth using DashboardLayoutState/StateWithHistory<DashboardLayout> plus AnyAction or a narrowly typed test helper instead?

- Reject invalid undo/redo transitions by keeping the current valid state
  instead of clearing history. Clearing it could let undoLayoutAction()
  read the emptied `past` as a fully-reverted, clean dashboard and drop
  the unsaved-changes / nav-away guard while edits were still visible.
  The HYDRATE_DASHBOARD reset remains the primary fix that prevents a
  stale/empty baseline from entering history in the first place.
- Type the reducer unit tests with DashboardLayout / AnyAction instead of
  `any`, per the repo's no-new-any guidance.
…ators

Match the import alias used in dashboardState.ts and dashboardLayout.ts
for naming consistency across the dashboard module (review nit).

@richardfogaca richardfogaca 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.

Posting on Richard's behalf - this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

Left one functional note below. The reducer-level reset looks directionally good, but this test setup appears to disagree with the redux-undo behavior locked in the frontend package.

Functional - worth addressing before merge

  • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts:128

    I don't think this setup creates the corrupt history that the assertion expects. The frontend lockfile resolves redux-undo to 1.1.0, and in that implementation clearHistory() with ignoreInitialState: true resets _latestUnfiltered to null; after line 124, the UPDATE_COMPONENTS at line 126 makes the layout valid but does not push the empty {} into past. A minimal reducer using the locked implementation leaves state.past.length === 0 here, so this expectation should fail in Jest.

    WDYT - could we either seed the corrupt history explicitly for the guard test, or adjust the test/root-cause explanation to match the locked redux-undo behavior?

@bito-code-review

bito-code-review Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #e569fc

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 4b2138d..a6e57f8
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

The frontend lockfile resolves redux-undo to 1.1.0, where clearHistory()
under ignoreInitialState resets _latestUnfiltered to null. The previous
guard test seeded its corrupt-history precondition via a premature
clearHistory + tracked action, which does not push a rootless layout onto
`past` in 1.1.0 — so that assertion would fail in Jest.

Rewrite the reducer tests to seed history states explicitly (mirroring
redux-undo's StateWithHistory shape) so the guard test exercises a real
rootless-undo precondition, add coverage that a normal valid->valid undo
still works, and correct the root-cause comment to match 1.1.0 behavior.
No production-logic change.
@ayushanand13

Copy link
Copy Markdown
Author

@richardfogaca thanks for the careful review — you were right about the locked version. The lockfile resolves redux-undo to 1.1.0, but I'd validated against 1.0.0-beta9-9-7 (the package.json range). They behave oppositely on the key detail: beta clearHistory() sets _latestUnfiltered = present, while 1.1.0 resets it to null under ignoreInitialState. So my old clearHistory() -> UPDATE_COMPONENTS setup never seeded a rootless past in 1.1.0 — exactly why those three assertions failed in CI.

Pushed 15ede2dc:

  • Reworked the reducer tests to seed history states explicitly (mirroring StateWithHistorypast/present/future is all undo() needs), so the guard test now exercises a genuine rootless past[0]. Added a valid -> valid undo test confirming the guard doesn't interfere with normal undo.
  • Corrected the root-cause comment to match 1.1.0.
  • Production reducer logic is unchanged; validated all 5 tests against a real redux-undo@1.1.0 build.

Context on 1.1.0: hydration leaves _latestUnfiltered null until the first real edit, so a freshly hydrated dashboard already keeps the stack empty (Undo disabled) — the original report's exact trigger doesn't reproduce on current master. I kept the reducer-level reset (your "directionally good") as a guarantee complementing #40569 for SPA navigation, and the guard as defense-in-depth so a rootless layout reaching the stack via any path can't crash Undo.

@bito-code-review

bito-code-review Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #14ed50

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: a6e57f8..15ede2d
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.test.ts
    • superset-frontend/src/dashboard/reducers/undoableDashboardLayout.ts
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rusackas rusackas self-requested a review June 22, 2026 17:09

@rusackas rusackas left a comment

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.

Thanks @ayushanand13, this looks solid. The reducer-level reset on hydration plus the rootless-layout guard reads cleanly, and returning state untouched on rejection nicely sidesteps the undoLayoutAction false-clean concern Richard raised. The valid -> valid undo test is a nice touch. Code LGTM; approving, happy to merge once CI's green, unless @richardfogaca feels differently.

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.37%. Comparing base (b612f57) to head (15ede2d).
⚠️ Report is 29 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #41252   +/-   ##
=======================================
  Coverage   64.36%   64.37%           
=======================================
  Files        2653     2653           
  Lines      144867   144880   +13     
  Branches    33421    33427    +6     
=======================================
+ Hits        93250    93265   +15     
+ Misses      49946    49944    -2     
  Partials     1671     1671           
Flag Coverage Δ
javascript 68.57% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ayushanand13

Copy link
Copy Markdown
Author

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

Labels

change:frontend Requires changing the frontend dashboard:editmode Related to te Dashboard edit mode size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard: Undo enabled on new dashboard create (?edit=true) causes TypeError on click

3 participants