sadpandajoe commented on code in PR #43108:
URL: https://github.com/apache/superset/pull/43108#discussion_r4129007004


##########
superset-frontend/src/dashboard/containers/DashboardPage.tsx:
##########
@@ -82,6 +90,68 @@ import {
 
 type NativeFilterConfigEntry = Partial<Filter> & { id: string };
 
+const DASHBOARD_FILTERS_STORAGE_PREFIX = 'dashboard__native_filters__';
+
+function getStorageKey(dashboardId: number, userId: number | undefined) {
+  // Scope the key to userId to prevent one user's filter state from
+  // leaking into another user's session on the same browser profile.
+  // Guest users (no userId) are not scoped — guest sessions are ephemeral.
+  return userId

Review Comment:
   This falls back to the unscoped key whenever `userId` is falsy, but the 
comments on both call sites below explicitly rely on `!= null` (not a 
truthiness check) so that an authenticated `userId` of `0` isn't treated as 
anonymous. This function still uses a truthiness check, so a real `userId` of 
`0` would read/write the guest-shared key instead of its own.
   ```suggestion
     return userId != null
   ```



##########
superset-frontend/src/dashboard/containers/DashboardPage.tsx:
##########
@@ -383,8 +545,58 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: 
PageProps) => {
   }, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
 
   const relevantDataMask = useSelector(selectRelevantDatamask);
+  const fullDataMask = useSelector(selectDataMask);
+  const nativeFilters = useSelector(selectNativeFilters);
   const activeFilters = useSelector(selectActiveFilters);
 
+  useEffect(() => {
+    // Skip persistence for unauthenticated/guest users: they have no stable
+    // identity to scope the key to, and restoring filter state across
+    // guest-token sessions would leak selections between unrelated sessions.
+    // Use == null (not !userId) so a valid userId of 0 is not treated as
+    // anonymous.
+    // Also skip when a historical version preview is active: the dashboard
+    // rehydrates with the snapshot's filter defaults, and every other guard
+    // would pass, causing those defaults to overwrite the user's live mask.
+    if (
+      !id ||
+      userId == null ||
+      isVersionPreviewActive ||
+      hydratedDashboardId !== id ||
+      !isDashboardHydrated.current
+    )
+      return;
+    // Persist only entries that correspond to configured native filters.
+    // This avoids saving chart customization or other transient dataMask
+    // entries that are not part of the user's filter selections.
+    const nativeFilterIds = Object.keys(nativeFilters);
+    // Do not overwrite a previously saved state with an empty mask.
+    // When the store clears dataMask on unmount (SPA navigation away), the
+    // hydratedDashboardId guard may still pass briefly before the next
+    // dashboard's hydration fires; skipping empty writes prevents that race
+    // from wiping the user's last valid selection.
+    if (nativeFilterIds.length === 0) return;
+    const nativeFilterMask = Object.fromEntries(
+      nativeFilterIds
+        .filter(filterId => filterId in fullDataMask)
+        .map(filterId => [filterId, fullDataMask[filterId]]),
+    );
+    // Also skip when the configuration is non-empty but dataMask has already
+    // been cleared (e.g. during SPA unmount before the next dashboard 
hydrates).
+    // The ref stays true across the switch so the hydratedDashboardId guard
+    // alone is not sufficient — a nativeFilterMask of {} would still overwrite
+    // the user's saved selection.
+    if (Object.keys(nativeFilterMask).length === 0) return;
+    saveDashboardFilters(id, userId, nativeFilterMask, nativeFilters);

Review Comment:
   Opening this dashboard via a permalink, a `native_filters_key`, or a `?f=` 
Rison link hydrates `dataMask` from that link, and this effect then persists 
whatever `dataMask` currently holds under the user's own storage key, with no 
check for whether the value came from an explicit URL rather than an 
interactive choice. The next time the same user opens the dashboard normally 
(no link in the URL), they get the shared link's filters instead of their own 
last selection. Could this effect skip persisting on the same load that just 
applied an explicit permalink/native-filters-key/Rison restore?



##########
superset-frontend/src/dashboard/containers/DashboardPage.tsx:
##########
@@ -82,6 +90,68 @@ import {
 
 type NativeFilterConfigEntry = Partial<Filter> & { id: string };
 
+const DASHBOARD_FILTERS_STORAGE_PREFIX = 'dashboard__native_filters__';
+
+function getStorageKey(dashboardId: number, userId: number | undefined) {
+  // Scope the key to userId to prevent one user's filter state from
+  // leaking into another user's session on the same browser profile.
+  // Guest users (no userId) are not scoped — guest sessions are ephemeral.
+  return userId
+    ? `${DASHBOARD_FILTERS_STORAGE_PREFIX}${userId}__${dashboardId}`
+    : `${DASHBOARD_FILTERS_STORAGE_PREFIX}${dashboardId}`;
+}
+
+function getSavedDashboardFilters(
+  dashboardId: number,
+  userId: number | undefined,
+) {
+  try {
+    const raw = localStorage.getItem(getStorageKey(dashboardId, userId));
+    return raw ? JSON.parse(raw) : null;
+  } catch {
+    return null;
+  }
+}
+
+function saveDashboardFilters(
+  dashboardId: number,
+  userId: number | undefined,
+  nativeFilterMask: Record<string, unknown>,
+  nativeFilters: Record<
+    string,
+    Filter | Divider | ChartCustomization | ChartCustomizationDivider
+  >,
+) {
+  try {
+    const key = getStorageKey(dashboardId, userId);
+
+    // Create a lightweight snapshot of the filter definition (targets and 
type)
+    // to detect when a dashboard editor retargets or reconfigures a filter,
+    // so we can invalidate the stale extraFormData.
+    const filterDefinitions = Object.fromEntries(
+      Object.entries(nativeFilters).map(([filterId, filter]) => [
+        filterId,
+        {
+          targets: filter.targets,
+          type: filter.filterType,
+        },
+      ]),
+    );
+
+    const nextValue = JSON.stringify({
+      dataMask: nativeFilterMask,
+      filterDefinitions,
+    });
+    // Skip the write if the value has not changed to avoid unnecessary
+    // synchronous main-thread work on every dataMask state update.
+    if (localStorage.getItem(key) !== nextValue) {
+      localStorage.setItem(key, nextValue);

Review Comment:
   This writes filter selections into localStorage keyed by the authenticated 
user, but nothing removes that entry when the user signs out. On a shared 
machine, the next person to use the browser can still read the previous user's 
saved filter values (which can include business-sensitive selections like a 
specific customer or account) from localStorage after logout, even though this 
app's logout flow already clears its other persisted client-side state for 
exactly this reason. Should logout also remove keys under this feature's 
storage prefix?



##########
superset-frontend/src/dashboard/containers/DashboardPage.test.tsx:
##########
@@ -630,3 +630,413 @@ test('clears undo history after hydrating the dashboard', 
async () => {
     .invocationCallOrder[0];
   expect(clearOrder).toBeGreaterThan(hydrateOrder);
 });
+
+// ---------------------------------------------------------------------------
+// localStorage filter persistence
+// ---------------------------------------------------------------------------
+
+test('restores native filter state from localStorage when no URL key is 
present', async () => {

Review Comment:
   None of the new tests exercise the two behaviors this 
dashboard-filter-persistence feature most recently changed: (1) that the save 
effect skips persisting while a historical version preview is active, including 
through an interrupted exit, and (2) that a `?f=` Rison link in the URL 
suppresses the localStorage restore. A regression on either would currently 
pass this suite. Could a test be added for each: one that applies a version 
preview with a different mask and asserts the live saved entry is unchanged 
after exiting, and one that loads with `?f=` present and asserts the saved 
entry is not merged into the resulting `dataMask`?



##########
superset-frontend/src/dashboard/containers/DashboardPage.tsx:
##########
@@ -381,8 +410,14 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: 
PageProps) => {
   }, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
 
   const relevantDataMask = useSelector(selectRelevantDatamask);
+  const fullDataMask = useSelector(selectDataMask);
   const activeFilters = useSelector(selectActiveFilters);
 
+  useEffect(() => {
+    if (!id || hydratedDashboardId !== id) return;
+    saveDashboardFilters(id, fullDataMask);

Review Comment:
   The save effect still isn't limited to native filters: the map it reads from 
also holds dividers and chart customizations, and it saves any key present 
there once that key also appears in `dataMask`, so a chart-customization change 
still gets written into this dashboard's storage entry. It's silently dropped 
again on restore, since that path checks the dashboard's native-filter 
configuration instead of this same map, but the write (and its serialization 
cost) still happens on every such change. Could the save side filter against 
the native-filter configuration the same way the restore side does, instead of 
against the full filters map?



##########
superset-frontend/src/dashboard/containers/DashboardPage.tsx:
##########
@@ -381,8 +523,47 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: 
PageProps) => {
   }, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
 
   const relevantDataMask = useSelector(selectRelevantDatamask);
+  const fullDataMask = useSelector(selectDataMask);
+  const nativeFilters = useSelector(selectNativeFilters);
   const activeFilters = useSelector(selectActiveFilters);
 
+  useEffect(() => {
+    // Skip persistence for unauthenticated/guest users: they have no stable
+    // identity to scope the key to, and restoring filter state across
+    // guest-token sessions would leak selections between unrelated sessions.
+    // Use == null (not !userId) so a valid userId of 0 is not treated as
+    // anonymous.
+    if (
+      !id ||
+      userId == null ||
+      hydratedDashboardId !== id ||
+      !isDashboardHydrated.current
+    )
+      return;
+    // Persist only entries that correspond to configured native filters.
+    // This avoids saving chart customization or other transient dataMask
+    // entries that are not part of the user's filter selections.
+    const nativeFilterIds = Object.keys(nativeFilters);
+    // Do not overwrite a previously saved state with an empty mask.
+    // When the store clears dataMask on unmount (SPA navigation away), the
+    // hydratedDashboardId guard may still pass briefly before the next
+    // dashboard's hydration fires; skipping empty writes prevents that race
+    // from wiping the user's last valid selection.
+    if (nativeFilterIds.length === 0) return;
+    const nativeFilterMask = Object.fromEntries(
+      nativeFilterIds
+        .filter(filterId => filterId in fullDataMask)
+        .map(filterId => [filterId, fullDataMask[filterId]]),
+    );
+    // Also skip when the configuration is non-empty but dataMask has already
+    // been cleared (e.g. during SPA unmount before the next dashboard 
hydrates).
+    // The ref stays true across the switch so the hydratedDashboardId guard
+    // alone is not sufficient — a nativeFilterMask of {} would still overwrite
+    // the user's saved selection.
+    if (Object.keys(nativeFilterMask).length === 0) return;
+    saveDashboardFilters(id, userId, nativeFilterMask, nativeFilters);

Review Comment:
   Closing an applied preview clears the preview state (and the flag this 
effect checks) before the live dashboard data and filter selections are 
re-hydrated. That leaves a render where the guard is already off but 
`dataMask`/`nativeFilters` still describe the historical snapshot, and this 
effect can persist that snapshot under the live key during that window. Could 
the guard track whether the live state has actually been restored, rather than 
only whether a preview is currently active, so persistence stays suspended 
until the exit rehydration completes?



-- 
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