kgabryje commented on PR #41551:
URL: https://github.com/apache/superset/pull/41551#issuecomment-5144304941

   # Independent review — three lenses over `075241a2f7`
   
   Ran three independent reviewers over `master...HEAD` at 
`075241a2f71a2009d1740cd65ca5252187c920c8` (merge base `7d2b184079`): 
correctness/server-contract, frontend state-and-races, and a global 
architecture pass. These are AI panels, same as the ones already posted above — 
so I re-read and confirmed every `file:line` below against source myself before 
posting. Where a reviewer's claim didn't survive that check, I've said so 
rather than repeating it.
   
   Given how much review this branch has already absorbed, I aimed them at 
*verifying the landed fixes* and *reconciling the PR body against the code* 
rather than another defect sweep. Findings the four prior panels already cover 
aren't repeated here.
   
   **Verdict: nothing here blocks merging it dark.** One real bug should be 
fixed before `VERSION_HISTORY` is turned on anywhere, and one class of issue is 
worth a look because it recurs.
   
   ---
   
   ## The unflagged change is clean
   
   All three reviewers cleared `canOverwriteSlice` independently, and I checked 
the history myself. Both load-bearing claims in the PR body hold:
   
   - **`owners` was provably dead.** #38831 (`33f0fc93ed`, 2026-07-08) removed 
`Slice.owners`; #41352 (`c0e5f5226d`, 2026-07-13) added the frontend owner 
branch five days later, against a model that no longer had the field. `Slice` 
today has `editors`/`viewers` and no `owners` attribute, and `Slice.data` emits 
no `owners` key. Dropping the branch is a genuine no-op.
   - **`extra_editors` is a strict widening.** Full case enumeration over 
`{slice-null, is_managed_externally, can_overwrite, admin, editors, 
extra_editors, owners}` gives NEW ⊇ OLD, with `extra_editors` the only 
newly-true set. It moves the client toward `is_editor` 
(`superset/security/manager.py:4935-4937` unions `get_extra_editor_subject_ids` 
with `resource.editors`), and `EXTRA_EDITORS_RESOLVER` can't produce a wire 
shape the helper mishandles — the server normalizes to `list[int]` at 
`security/manager.py:127-167`.
   
   The withdrawal was correct. Worth stating plainly so it doesn't get 
re-litigated.
   
   ---
   
   ## 1. Re-selecting the version you're already previewing wedges the preview 
permanently
   
   This is the one I'd fix before the flag goes on anywhere.
   
   `SET_VERSION_PREVIEW` re-enters the applying state unconditionally:
   
   ```ts
   // reducer.ts:181-186
   case SET_VERSION_PREVIEW:
     return { ...state, preview: action.preview, isPreviewApplying: true };
   ```
   
   Only `ChartVersionPreview`'s `.finally` or the dashboard hook's 
`apply().then` can leave it, and both sit inside effects keyed on `versionUuid` 
(`ChartVersionPreview.tsx:214` — `[dispatch, entityUuid, versionUuid]`). 
Dispatch the **same** `versionUuid` again and the deps are unchanged, so the 
effect never re-runs and nothing ever announces completion. The dashboard hook 
has an independent second route to the same wedge — 
`useDashboardVersionPreview.ts:286` early-returns on `appliedVersionRef.current 
=== versionUuid` without dispatching `versionPreviewApplied()`.
   
   **It's reachable by ordinary clicking.** `ActionRow.tsx:212` is 
`onClick={onPreview}` on *every* change row, including rows of the group 
currently being previewed. `previewIntent()` (`SaveGroupItem.tsx:261`) sees 
`isCurrent === false` and calls through with an identical `versionUuid`.
   
   **Result:** `isPreviewApplying` stays `true` for the rest of the page's 
life. `PreviewBanner.tsx:82-83` renders *"Loading historical version"* over a 
snapshot that is fully applied and on screen, and `PreviewBanner.tsx:101` 
(`canRestore && !isApplying`) withholds Restore forever. Only exiting and 
re-entering clears it. This is exactly the reading the comment at 
`PreviewBanner.tsx:79-81` says a user must never be given, and `e33ba0cc27` 
closed the failure path but not this one.
   
   Reproduced empirically against the real reducer and store — a paste-in 
regression test is at the end of this comment. The narrowest fix is at the 
reducer, since it closes both call sites at once: make `SET_VERSION_PREVIEW` a 
no-op when `action.preview.versionUuid === state.preview?.versionUuid`.
   
   ---
   
   ## 2. Preview is not a faithful preview of what restore will do
   
   Three reviewers reached this from three unrelated directions, which is why 
I'd rank it above anything else structural:
   
   - **Chart membership.** Restore rebuilds it from the 
`dashboard_slices_version` shadow, validity-windowed at the target transaction 
(`superset/versioning/restore.py:190-249`). Preview rebuilds it by scanning 
`position_json` for chart ids (`useDashboardVersionPreview.ts:97-103`). These 
disagree whenever `dashboard.slices` and `position_json` disagree — e.g. a 
chart still a member but dragged out of the layout. Preview shows it absent; 
restore re-attaches it.
   - **Scalar properties.** `Dashboard.__versioned__` excludes only 
ownership/audit columns — so `description`, `theme_id`, 
`certification_details`, `slug` and `published` *are* captured and *are* 
restored. The client's `DashboardVersionSnapshot` (`types.ts:112-122`) names 
none of them, and preview overrides only title, CSS, metadata and position. 
Preview an old version and it still renders the live theme and description; 
restore changes both.
   - **Chart definitions.** `resolveSnapshotCharts` reuses `liveCharts` 
verbatim, so each chart in a previewed dashboard renders with its *current* 
definition, not the one it had at that version. 
`docs/.../version-history.mdx:66-69` says preview *"reconstructs the layout as 
it was"* without noting this.
   
   For a feature whose safety story is "look before you destroy," the gap 
between what preview shows and what restore does seems worth closing — or at 
least documenting. The membership half needs the snapshot endpoint to return 
the membership set; the scalar half is frontend-only.
   
   Related: **"Open as new"** sends only title, CSS and JSON metadata to the 
copy endpoint, so the new dashboard gets none of the version's description, 
theme, certification, publication state or slug.
   
   ---
   
   ## 3. Several fixes landed on one entity type but not its mirror
   
   Calling this out as a pattern rather than four tickets, because it predicts 
where the rest are:
   
   - **`extra_editors` fixed for charts, not dashboards.** 
`canUserEditDashboard` → `isUserDashboardEditor` intersects `dashboard.editors` 
only (`dashboard/util/permissionUtils.ts:55-63`), while the server's 
`is_editor` unions `extra_editors`. A user granted editorship through 
`EXTRA_EDITORS_RESOLVER` is refused the menu, the Restore control, and 
`?version_history=true` on a dashboard the API would let them restore. 
`Dashboard` doesn't type the field at all (`types/Dashboard.ts:22-42`).
   - **Unmount guard fixed for dashboards, not charts.** `aa76d865b3` added 
`isMountedRef` to the dashboard path. The chart path at 
`ExploreVersionHistory.tsx:199-203` still dispatches `hydrateExplore` 
unconditionally when the fetch resolves — the uuid check at `:186-191` runs 
*before* the request and can't protect its completion. Restore chart A, 
navigate to chart B, let A's `/api/v1/explore/` response land: B's Explore 
state is replaced with A's payload.
   - **Empty-state edit link fixed in one of two places.** `2c275d34d0` gated 
`DashboardBuilder.tsx:816-821`; the identical control inside an empty *tab* 
(`gridComponents/Tab/Tab.tsx:350-363`) was missed. Not exploitable — 
`pointer-events: none` and the `onKeyDownCapture` handler both cover it — so it 
renders as a live-looking link that silently does nothing.
   - **`programmatic` mark missed a writer.** `40e5fb7b26` marked six sites; 
`explore/components/Control.tsx:88` calls `setControlValue?.(name, 
props.default)` inside an effect (the reset-on-hide path) with no mark. 
Changing control A hides control B, and B's reset is attributed to the user as 
*"Changed 'B'"*. Worth noting the mark is opt-out (`exploreActions.ts:116` 
defaults `programmatic` to `false`), so a future `setControlValue` inside an 
effect regresses this silently with nothing failing CI. Plugin writes route 
through `ExploreViewContainer/index.tsx:994-998`, which passes no options 
object, so they can't opt in at all today.
   
   Also in this family, and the reason the last one matters: the chart restore 
gate uses the session log as its dirty signal (`useVersionActions.tsx:98-102`), 
which is wrong in both directions. **False negative** — renaming a chart 
dispatches `UPDATE_CHART_TITLE`, not `SET_FIELD_VALUE`, so it never reaches the 
middleware; restore proceeds and `hydrateExplore` resets `sliceName` from the 
server, discarding the rename with no warning. **False positive** — the log is 
append-only and never diffed against the original, so `row_limit` 100 → 200 → 
100 leaves an entry, restore is refused with *"Save or discard your unsaved 
changes…"*, and Explore has no discard affordance, so a page reload is the only 
way out.
   
   ---
   
   ## 4. Smaller confirmed items
   
   - **A `?edit=true` deep link can survive a preview round-trip into edit 
mode.** Entry correctly forces `editMode: false` and explains why 
(`useDashboardVersionPreview.ts:349-354`), but the exit path (`:385-389`) and 
the post-restore path (`:275`) pass no override and fall through to the 
URL-derived value at `hydrate.ts:126`. Arrive at `?edit=true`, leave edit mode 
by saving (the URL keeps the param — only `discardChanges` strips it), preview, 
then **Close preview** → the dashboard silently enters edit mode. 
`hasUnsavedChanges` is reset by the hydrate so nothing is written without 
further action.
   - **Timeline re-renders on every keystroke.** `searchTerm` is controlled 
state in the container; only the *fetch* is debounced, not the render. None of 
`SaveGroupItem`, `ActionRow`, `RelatedUpdateRow`, `CurrentVersionSection` or 
`VersionHistoryPanel` is memoized, and there's no virtualization. Per row per 
keystroke: a dayjs UTC parse + `.local()` + `.format()`, `describeRecord`, and 
emotion re-serialization of ~8 prop-dependent `styled` components. `loadMore` 
chases up to `MAX_CHAINED_PAGES = 8` × `PAGE_SIZE = 25` per click with no 
ceiling across clicks, so this grows without bound. `memo` on the four row 
components plus lifting the search input would cover it.
   - **A `loadMore` click can drop the "Current" probe.** The `include=self` 
probe from `3b7a1fb944` is guarded on the same `fetchIdRef` that `loadMore` 
bumps (`useVersionActivity.ts:143`, `:186`). A click landing between the page-0 
response and the probe's response discards it permanently, so `newestGroup` 
stays null and the newest save renders without its Current tag and with 
preview/restore affordances. Restoring it is a server no-op 
(`useVersionActions.tsx:173` toasts *"Already at this version"*), so this is a 
labeling defect on a narrow window — a separate counter for the probe would 
close it.
   - **Wire types drift from the server in three places:** `ActivityOperation` 
omits `"update"` (emitted when a field exceeds the record cap); 
`ActivityRecord.path` is required but deleted related records are deliberately 
redacted to `null`; `ChartVersionSnapshot.query_context` is required but 
`Slice.__versioned__` excludes it and the chart version table has no such 
column. All three are currently tolerated at runtime, but they're what let the 
impossible test fixtures through.
   - **i18n:** `describeRecord` assembles sentences from translated fragments — 
`t('Added a %s', t('chart'))` at `display.ts:272-289` — which 
`display.ts:394-397` itself argues against. Unfixable for languages that 
inflect the noun or need article agreement, and it's the largest translatable 
surface in the panel. Dates are hardcoded US formats (`display.ts:35`, `:44`) 
on every row; dayjs `LLL`/`l LT` would be a drop-in.
   - **`RelatedUpdateRow.tsx:151-154`** locates the link by 
`headline.lastIndexOf(record.entity_name)`. When `headline` is a 
server-provided summary, nothing guarantees the name appears at the intended 
position — for a one-character entity name it will match an arbitrary character 
and split the sentence around it.
   - **The `include` filter persists across entities.** 
`SET_VERSION_HISTORY_INCLUDE` is never reset on close or navigation. There's a 
test asserting the persistence (`reducer.test.ts:77`), so this may be 
deliberate — it reads as a bug from the user's side.
   
   ---
   
   ## 5. Two things that ship with the flag off
   
   Flagging these only because they reach deployments that never opted in. Both 
look benign; neither appears to have been visually verified.
   
   - `DashboardWrapper.tsx:34` changes `grid-template-columns` from `auto 1fr` 
to `auto 1fr auto` for **every** dashboard. The third column collapses to 0 
when empty, so I'd expect no visual change.
   - Four new wrapper `<div>`s ship unconditionally (two around the filter bars 
in `DashboardBuilder.tsx`, two around 
`DataSourcePanel`/`ControlPanelsContainer` in `ExploreViewContainer`), several 
carrying `height: 100%` inside sticky/flex containers. The `inert` and 
`pointer-events` attributes on them are conditional; the elements are not.
   
   A screenshot pass with the flag off would close both.
   
   ---
   
   ## 6. Architectural note
   
   Offering this as context for the roadmap rather than as an ask on this PR — 
the feature works and is contained.
   
   The PR implements preview twice, differently. Chart preview is a **detached 
render**: 245 lines, local state, never writes the global store. Dashboard 
preview **hydrates the snapshot into the live global store** and unwinds it: 
404 lines, 7 refs, three interleaved state machines. Nearly every expensive 
mechanism in the feature exists only for the dashboard half — the `liveDataRef` 
cache of the whole payload and its three invalidation effects, the save-signal 
listener, the `dataMask` save/replay, the `editMode` override on 
`hydrateDashboard`, the store-level `isPreviewApplying` flag, and the gate 
wrappers.
   
   The consequence worth naming: because the *live* store holds historical 
data, those gates are **safety-critical rather than cosmetic**. On the chart 
side the equivalent code (`ExploreViewContainer/index.tsx:1109-1125`, 
`1179-1198`) is pure visual dimming, because the store was never poisoned. The 
four near-duplicate preview-gate wrappers noted in the PR body aren't cosmetic 
duplication — they're the failure-mode surface of the mechanism. The unwind is 
also lossy by construction: `hydrateWith` passes `activeTabs: null, 
chartStates: null`, and the undo stack is simply gone.
   
   An alternative the codebase already supports: a **nested Redux store**. 
`setupStore()` (`views/store.ts:175-195`) is already parameterized on 
`initialState` and already runs a second instance in production 
(`views/menu.tsx:39`), and no component in `src/dashboard/` imports the 
singleton — they all read the default react-redux context. A `<Provider 
store={previewStore}>` around `DashboardContainer` would redirect the whole 
tree. Preview becomes "mount a store seeded with the snapshot," exit becomes 
"unmount," and the cache, the listener, the staleness check, the dataMask 
replay, the `editMode` override and `isPreviewApplying` all disappear — along 
with the possibility of a save writing historical layout to the live entity, 
since there's no live store in scope.
   
   The reason to weigh this *before* datasets rather than after: the current 
shape doesn't extend. A dataset preview needs a third variant, and each new 
entity kind multiplies the gate surface.
   
   ---
   
   ## Checked and clean
   
   Stated explicitly so the next round doesn't re-spend budget on them:
   
   - **`0dc70e639c`** — `VERSION_RESTORED` is properly scoped; all three 
consumers compare uuid before reacting (`useDashboardVersionPreview.ts:251`, 
`DashboardVersionHistory.tsx:150`, `ExploreVersionHistory.tsx:186`).
   - **`aa76d865b3`** — the dashboard unmount guard is real, and 
`EXPLORE_REHYDRATION_CONCURRENCY = 6` is applied. The other `Promise.all`s are 
bounded too.
   - **`2fbfab3695`** — effect ordering is correct: the save effect is declared 
before the apply effect, so `saveSignalAtStart` reads an already-synced ref.
   - **`dc473b6d2a`** — `recordKey` includes `from_value`/`to_value`.
   - **`e33ba0cc27`** — the failure path *is* covered; only the re-selection 
path above isn't.
   - **`6f37cf732f` / `075241a2f7`** — the single-funnel claim holds: 
`restoreVersion` has exactly one caller, reachable only through 
`requestRestore` → `RestoreConfirmModal`, and all three UI entry points go 
through it.
   - **`87c98e8e13`** — `git diff <merge-base>..HEAD -- package-lock.json` is 
empty; the lockfile is byte-identical to master.
   - **`truncated`**, pagination, `include` and search semantics all match the 
server parser.
   - **Preview writes** — every write path during a dashboard preview was 
enumerated (header Save/Edit/Share/SaveAs, kebab, inline title, both filter 
bars, the grid, both `setEditMode(true)` sites). *"Preview never writes"* 
holds, modulo the `?edit=true` exit path above.
   - **Explore preview gates are symmetric by construction** — all seven are 
pure functions of the selector read fresh each render, with no state latch, so 
none can be left disabled after exit.
   - **No authorization bypass and no XSS.** Read endpoints call resource-typed 
`raise_for_access`; restore is route-mapped to `write` and separately calls 
`raise_for_editorship`. The feature renders server strings as React text 
children with no `dangerouslySetInnerHTML`.
   - **The new Playwright is not dead code.** `activity-log.spec.ts` is under 
the required tree, `playwright.config.ts` includes it in Chromium, and the 
required workflow runs with 
`SUPERSET_CONFIG=tests.integration_tests.superset_test_config`, which enables 
both `VERSION_HISTORY` and capture.
   - 274 existing Jest tests across the feature and the touched 
explore/dashboard suites pass.
   
   Two notes on scope: `?version_history=true` gating history on *restore* 
permission makes the UI strictly narrower than the read API (`can_read` + 
`raise_for_access`), which the code comment indicates is deliberate — flagging 
it as a product decision worth stating, not a defect. And 
`is_managed_externally` is enforced client-side only; the restore command 
doesn't check it, so an authorized editor can call the endpoint directly. 
That's a backend follow-up against already-merged code, not an ask here, but 
`version-history.mdx:81-83` states it unconditionally.
   
   Separately, and **not caused by this PR** (`exploreReducer.ts` isn't in the 
diff): `ChartGetResponseSchema.editors` is a `SubjectResponseSchema`, i.e. 
`{id, label, …}` with no `value` field, but `exploreReducer.ts:618-619` 
normalizes with `typeof editor === 'number' ? editor : editor.value`. After the 
Properties modal saves and refetches, that writes `undefined` ids into 
`state.explore.slice.editors`. Worth its own issue.
   
   What this review did **not** do: no manual walkthrough of the UI, no 
Playwright run, no profiling of the panel.
   
   ---
   
   <details>
   <summary><b>Regression test for finding 1</b> — passes on current head, 
which is the bug</summary>
   
   Drop in as `src/features/versionHistory/previewReselect.test.tsx`. After the 
fix, the final assertion should become `toBe(false)`.
   
   ```tsx
   import { act } from 'react';
   import { createStore, render, waitFor } from 'spec/helpers/testing-library';
   import reducerIndex from 'spec/helpers/reducerIndex';
   import { getChartDataRequest } from 'src/components/Chart/chartAction';
   import type { VersionHistoryState } from './types';
   import { setVersionPreview, type VersionHistoryRootState } from './reducer';
   import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
   import ChartVersionPreview from './ChartVersionPreview';
   
   jest.mock('./api', () => ({
     fetchDatasourceMetadata: jest.fn(),
     fetchVersionSnapshot: jest.fn(),
   }));
   jest.mock('./PreviewBanner', () => ({ __esModule: true, default: () => null 
}));
   jest.mock('src/components/Chart/chartAction', () => ({
     getChartDataRequest: jest.fn(),
     handleChartDataResponse: jest.fn(),
   }));
   jest.mock('src/explore/exploreUtils', () => ({
     ...jest.requireActual('src/explore/exploreUtils'),
     getQuerySettings: () => [false],
   }));
   jest.mock('@superset-ui/core', () => ({
     ...jest.requireActual('@superset-ui/core'),
     SuperChart: () => <div data-test="super-chart" />,
   }));
   
   const { handleChartDataResponse } = jest.requireMock(
     'src/components/Chart/chartAction',
   );
   
   const PREVIEW = {
     entityUuid: 'chart-uuid',
     versionUuid: 'version-uuid',
     transactionId: 1,
     headline: 'A save',
     issuedAt: '2026-07-01T00:00:00Z',
   };
   
   const previewState = (): VersionHistoryState => ({
     isPanelOpen: true,
     entityType: 'chart',
     include: 'all',
     isPreviewApplying: true,
     preview: PREVIEW,
     sessionLog: [],
     restoreCount: 0,
     lastRestoredEntityUuid: null,
   });
   
   beforeEach(() => {
     jest.clearAllMocks();
     (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
       params: '{"metric":"count"}',
       viz_type: 'table',
       datasource_id: 5,
       datasource_type: 'table',
     });
     (fetchDatasourceMetadata as jest.Mock).mockResolvedValue({});
     (getChartDataRequest as jest.Mock).mockResolvedValue({ response: {}, json: 
{} });
     (handleChartDataResponse as jest.Mock).mockResolvedValue([{ data: [] }]);
   });
   
   test('re-selecting the already-previewed version wedges isPreviewApplying', 
async () => {
     const store = createStore(
       {
         versionHistory: previewState(),
         explore: {
           datasource: { id: 5, type: 'table', columns: [], metrics: [] },
           slice: { editors: [{ id: 1 }] },
         },
         user: { userId: 1, roles: {} },
       },
       reducerIndex,
     );
     render(<ChartVersionPreview />, { useTheme: true, store });
   
     const applying = () =>
       (store.getState() as unknown as VersionHistoryRootState).versionHistory
         .isPreviewApplying;
   
     await waitFor(() => expect(applying()).toBe(false)); // first apply settles
   
     // The user clicks a change row inside the group they are already 
previewing:
     // ActionRow.tsx:212 -> SaveGroupItem.tsx:265 -> 
ExploreVersionHistory.tsx:244.
     act(() => {
       store.dispatch(setVersionPreview({ ...PREVIEW }));
     });
     expect(applying()).toBe(true); // reducer.ts:186 re-enters unconditionally
   
     // The effect deps ([dispatch, entityUuid, versionUuid]) are unchanged, so 
it
     // never re-runs and nothing dispatches versionPreviewApplied(). Flush to 
show
     // it is wedged, not slow.
     await act(async () => {
       await new Promise(resolve => {
         setTimeout(resolve, 50);
       });
     });
     expect(applying()).toBe(true); // BUG: stuck forever; Restore never 
returns.
   });
   ```
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to