From e0d6bbbadf57606b2abb893f92c3f9ab735293cb Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Mon, 27 Jul 2026 15:27:46 +0300 Subject: [PATCH 1/6] Add URL query-parameter filtering for dashboards Filters are read from and written back to the URL, so a filtered dashboard view is a shareable link. Select values from the URL are validated against the filter's options and ignored if not valid. --- docs/dashboards/filters.md | 17 +++++++ frontend/src/components/DashboardView.tsx | 41 ++++++++++++++--- frontend/src/lib/filterParams.ts | 54 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/filterParams.ts diff --git a/docs/dashboards/filters.md b/docs/dashboards/filters.md index 545d847..cb24184 100644 --- a/docs/dashboards/filters.md +++ b/docs/dashboards/filters.md @@ -111,6 +111,23 @@ A free-form text input. | `year_to_date` | January 1 to today | | `all_time` | 1970-01-01 to 2099-12-31 | +## Sharing Filter State via URL + +Filter values are reflected in the page's URL query string. Each filter is one query parameter keyed by its `name`: + +``` +/dashboards/sales?region=Europe&date_range=2025-01-01..2025-03-31 +``` + +- **Multi-select** values are comma-separated: `?region=Europe,APAC`. +- **Date-range** values use `start..end`: `?date_range=2025-01-01..2025-03-31`. +- Opening a link applies the URL values on load; queries run with them immediately. + +A few behaviors worth noting: + +- **Defaults stay out of the URL.** The URL only captures values the viewer actively changes, keeping links clean. A link with no parameter for a filter uses that filter's current `default` from the YAML. +- **Invalid values are ignored.** URL values are validated before use, so a filter falls back to its default when the value doesn't fit its type. For `select` with a static `options.values` list, values that aren't real options are discarded (per-value for multi-select; if none are valid, the default is used). `date` and `date-range` values must be `YYYY-MM-DD` dates (`date-range` written as `start..end`); anything else is rejected. `number` values must parse as a number. + ## Using Filters in Queries Filter values are injected into SQL via [Jinja templating](/dashboards/queries). Access them with `filters.`: diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index c577f0f..d890900 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -1,9 +1,10 @@ import { useState, useMemo, useCallback, useEffect, useRef } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { useDashboard } from "../hooks/useDashboard"; import { useWidgetQuery } from "../hooks/useWidgetQuery"; import { useTemplate } from "../themes/TemplateProvider"; import { resolvePreset } from "../themes/bruin/FilterBar"; +import { fromParam, toParam } from "../lib/filterParams"; import { YamlPanel } from "./YamlPanel"; import { ExportMenuButton, type ExportMenuItem } from "./ExportMenuButton"; import { fetchDashboardData } from "../api/client"; @@ -121,13 +122,23 @@ export function DashboardView() { const onResizeStart = useCallback(() => setIsResizing(true), []); const onResizeEnd = useCallback(() => setIsResizing(false), []); - const defaultFilters = useMemo( - () => dashboard ? buildDefaultFilters(dashboard) : null, - [dashboard], - ); + const [searchParams, setSearchParams] = useSearchParams(); + + const baseFilters = useMemo(() => { + if (!dashboard) return null; + // Start from the dashboard defaults, then apply any values from the URL. + const values = buildDefaultFilters(dashboard); + for (const f of dashboard.filters ?? []) { + const raw = searchParams.get(f.name); + if (raw == null) continue; + const parsed = fromParam(f, raw); + if (parsed !== undefined) values[f.name] = parsed; + } + return values; + }, [dashboard, searchParams]); const [filters, setFilters] = useState | null>(null); - const activeFilters = filters ?? defaultFilters; + const activeFilters = filters ?? baseFilters; const { DashboardLayout, @@ -221,7 +232,23 @@ export function DashboardView() { const currentTab = activeTab ?? (hasTabs ? tabNames[0] : null); const handleFilterChange = (filterName: string, value: unknown) => { - setFilters((prev) => ({ ...prev, [filterName]: value })); + // Seed from the currently active values so other filters are preserved. + setFilters((prev) => ({ ...(prev ?? activeFilters ?? {}), [filterName]: value })); + + // Reflect the change in the URL query string. + const filter = dashboard.filters?.find((f) => f.name === filterName); + if (filter) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const param = toParam(filter, value); + if (param == null) next.delete(filterName); + else next.set(filterName, param); + return next; + }, + { replace: true }, + ); + } }; const filterBar = dashboard.filters ? ( diff --git a/frontend/src/lib/filterParams.ts b/frontend/src/lib/filterParams.ts new file mode 100644 index 0000000..7aa69cb --- /dev/null +++ b/frontend/src/lib/filterParams.ts @@ -0,0 +1,54 @@ +import type { Filter } from "../types/dashboard"; +import { resolvePreset } from "../themes/bruin/FilterBar"; + +/** + * Convert filter values to/from URL query-string params, + * so each filter is one param keyed by its name. + */ + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** URL param string → typed filter value. Returns undefined to ignore the param. */ +export function fromParam(filter: Filter, raw: string): unknown { + switch (filter.type) { + case "number": { + const n = Number(raw); + return raw !== "" && !Number.isNaN(n) ? n : undefined; + } + case "date": { + const v = raw.trim(); + return ISO_DATE_RE.test(v) ? v : undefined; + } + case "date-range": { + const preset = resolvePreset(raw); // e.g. "last_30_days" + if (preset) return preset; + const [start = "", end = ""] = raw.split(/\.\.|,/).map((s) => s.trim()); // or "2025-01-01..2025-03-31" + return ISO_DATE_RE.test(start) && ISO_DATE_RE.test(end) ? { start, end } : undefined; + } + case "select": { + // Only accept values that are actually offered as options. This ignores + // anything injected into the URL that isn't a real choice. + const allowed = filter.options?.values; + if (filter.multiple) { + const vals = raw.split(",").map((s) => s.trim()).filter(Boolean); + if (!allowed) return vals; + const filtered = vals.filter((v) => allowed.includes(v)); + return filtered.length ? filtered : undefined; + } + return !allowed || allowed.includes(raw) ? raw : undefined; + } + default: + return raw; // text + } +} + +/** Typed filter value → URL param string. Returns null to drop the param. */ +export function toParam(filter: Filter, value: unknown): string | null { + if (value == null || value === "") return null; + if (Array.isArray(value)) return value.length ? value.map(String).join(",") : null; + if (filter.type === "date-range") { + const v = value as { start?: string; end?: string }; + return v.start && v.end ? `${v.start}..${v.end}` : null; + } + return String(value); +} From 8468fed96c6ca2e32941819bcdade0c25ffc0a89 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Mon, 27 Jul 2026 16:56:46 +0300 Subject: [PATCH 2/6] Document URL-shareable filters in create-dashboard skill --- .claude/skills/create-dashboard/SKILL.md | 2 ++ cmd/skill_templates/create-dashboard/SKILL.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.claude/skills/create-dashboard/SKILL.md b/.claude/skills/create-dashboard/SKILL.md index 78471be..76e4c38 100644 --- a/.claude/skills/create-dashboard/SKILL.md +++ b/.claude/skills/create-dashboard/SKILL.md @@ -177,6 +177,8 @@ filters: **Date range presets:** `today`, `yesterday`, `last_7_days`, `last_30_days`, `last_90_days`, `this_month`, `last_month`, `this_quarter`, `this_year`, `year_to_date`, `all_time`. If `options.presets` is omitted, a default set is shown. Users can always pick "Custom range" for arbitrary dates. +**Shareable URLs:** filter values are kept in the URL query string, so you can share a filtered dashboard as a link. Each filter becomes one query parameter named after it, for example `?region=Europe&date_range=last_30_days`. When a select has `multiple: true` the values are comma separated, and a `date-range` is either a preset key or `start..end`. Anything read from the URL is checked against the filter's type and options, and ignored if it doesn't match. + --- ## Named Queries diff --git a/cmd/skill_templates/create-dashboard/SKILL.md b/cmd/skill_templates/create-dashboard/SKILL.md index 4bcf394..ed08571 100644 --- a/cmd/skill_templates/create-dashboard/SKILL.md +++ b/cmd/skill_templates/create-dashboard/SKILL.md @@ -125,6 +125,8 @@ Select filters support `multiple: true` for multi-select. The value is a list {% endif %} ``` +Filter values are kept in the URL query string, so you can share a filtered dashboard as a link. Each filter becomes one query parameter named after it, for example `?region=Europe&date_range=last_30_days`. When a select has `multiple: true` the values are comma separated, and a `date-range` is either a preset key or `start..end`. Anything read from the URL is checked against the filter's type and options, and ignored if it doesn't match. + ## Current Viewer (`bruin.user_email`) `{{ bruin.user_email }}` is the email of the signed-in user viewing the dashboard — a Bruin Cloud runtime feature that resolves per viewer, so one dashboard can show each person only their own rows: From b818b194c15e8a64e1e4afecef521d463f99e828 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Mon, 27 Jul 2026 21:58:42 +0300 Subject: [PATCH 3/6] Harden URL filter parsing and use repeated params for multi-select Reject non-finite numbers and impossible dates from the URL, and encode multi-select as repeated params so option values containing commas survive. --- frontend/src/components/DashboardView.tsx | 26 +++++++++++++--- frontend/src/lib/filterParams.ts | 38 ++++++++++++++--------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index d890900..61eb4a1 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -4,7 +4,7 @@ import { useDashboard } from "../hooks/useDashboard"; import { useWidgetQuery } from "../hooks/useWidgetQuery"; import { useTemplate } from "../themes/TemplateProvider"; import { resolvePreset } from "../themes/bruin/FilterBar"; -import { fromParam, toParam } from "../lib/filterParams"; +import { fromParam, toParam, keepAllowedValues } from "../lib/filterParams"; import { YamlPanel } from "./YamlPanel"; import { ExportMenuButton, type ExportMenuItem } from "./ExportMenuButton"; import { fetchDashboardData } from "../api/client"; @@ -129,6 +129,14 @@ export function DashboardView() { // Start from the dashboard defaults, then apply any values from the URL. const values = buildDefaultFilters(dashboard); for (const f of dashboard.filters ?? []) { + // Multi-select uses repeated params (?x=a&x=b) so values may contain commas. + if (f.type === "select" && f.multiple) { + const raws = searchParams.getAll(f.name).map((s) => s.trim()).filter(Boolean); + if (!raws.length) continue; + const kept = keepAllowedValues(f, raws); + if (kept.length) values[f.name] = kept; + continue; + } const raw = searchParams.get(f.name); if (raw == null) continue; const parsed = fromParam(f, raw); @@ -241,9 +249,19 @@ export function DashboardView() { setSearchParams( (prev) => { const next = new URLSearchParams(prev); - const param = toParam(filter, value); - if (param == null) next.delete(filterName); - else next.set(filterName, param); + // Multi-select writes one repeated param per value (comma-safe). + if (filter.type === "select" && filter.multiple) { + next.delete(filterName); + if (Array.isArray(value)) { + for (const v of value) { + if (v != null && v !== "") next.append(filterName, String(v)); + } + } + } else { + const param = toParam(filter, value); + if (param == null) next.delete(filterName); + else next.set(filterName, param); + } return next; }, { replace: true }, diff --git a/frontend/src/lib/filterParams.ts b/frontend/src/lib/filterParams.ts index 7aa69cb..262ff52 100644 --- a/frontend/src/lib/filterParams.ts +++ b/frontend/src/lib/filterParams.ts @@ -2,39 +2,44 @@ import type { Filter } from "../types/dashboard"; import { resolvePreset } from "../themes/bruin/FilterBar"; /** - * Convert filter values to/from URL query-string params, - * so each filter is one param keyed by its name. + * Convert filter values to/from URL query-string params, so each filter is one + * param keyed by its name. Multi-select uses repeated params (?x=a&x=b), read + * and written in DashboardView so option values may safely contain commas. */ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// True only for a real calendar date in YYYY-MM-DD form. The regex alone checks +// shape, not validity, so this also rejects impossible dates like 2026-99-99 or +// 2026-02-30 (which would otherwise roll over). +function isRealDate(s: string): boolean { + if (!ISO_DATE_RE.test(s)) return false; + const d = new Date(`${s}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; +} + /** URL param string → typed filter value. Returns undefined to ignore the param. */ export function fromParam(filter: Filter, raw: string): unknown { switch (filter.type) { case "number": { + if (raw === "") return undefined; const n = Number(raw); - return raw !== "" && !Number.isNaN(n) ? n : undefined; + return Number.isFinite(n) ? n : undefined; } case "date": { const v = raw.trim(); - return ISO_DATE_RE.test(v) ? v : undefined; + return isRealDate(v) ? v : undefined; } case "date-range": { const preset = resolvePreset(raw); // e.g. "last_30_days" if (preset) return preset; const [start = "", end = ""] = raw.split(/\.\.|,/).map((s) => s.trim()); // or "2025-01-01..2025-03-31" - return ISO_DATE_RE.test(start) && ISO_DATE_RE.test(end) ? { start, end } : undefined; + return isRealDate(start) && isRealDate(end) ? { start, end } : undefined; } case "select": { - // Only accept values that are actually offered as options. This ignores - // anything injected into the URL that isn't a real choice. + // Single-select only; multi-select is read from repeated params in + // DashboardView. Only accept a value the filter actually offers. const allowed = filter.options?.values; - if (filter.multiple) { - const vals = raw.split(",").map((s) => s.trim()).filter(Boolean); - if (!allowed) return vals; - const filtered = vals.filter((v) => allowed.includes(v)); - return filtered.length ? filtered : undefined; - } return !allowed || allowed.includes(raw) ? raw : undefined; } default: @@ -45,10 +50,15 @@ export function fromParam(filter: Filter, raw: string): unknown { /** Typed filter value → URL param string. Returns null to drop the param. */ export function toParam(filter: Filter, value: unknown): string | null { if (value == null || value === "") return null; - if (Array.isArray(value)) return value.length ? value.map(String).join(",") : null; if (filter.type === "date-range") { const v = value as { start?: string; end?: string }; return v.start && v.end ? `${v.start}..${v.end}` : null; } return String(value); } + +/** Keep only multi-select values the filter offers (empty option list = accept as-is). */ +export function keepAllowedValues(filter: Filter, vals: string[]): string[] { + const allowed = filter.options?.values; + return allowed ? vals.filter((v) => allowed.includes(v)) : vals; +} From 651bf18c13b0c19226fc3abe914c9fa428958600 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Tue, 28 Jul 2026 08:14:52 +0300 Subject: [PATCH 4/6] Re-apply URL filters on external navigation instead of shadowing them Local filter overrides are dropped when the URL query string changes on its own (navigating to the same dashboard with different params), while a self-write guard preserves in-progress input during the user's own edits. --- frontend/src/components/DashboardView.tsx | 58 +++++++++++++++-------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index 61eb4a1..d892377 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -148,6 +148,10 @@ export function DashboardView() { const [filters, setFilters] = useState | null>(null); const activeFilters = filters ?? baseFilters; + // Set by handleFilterChange so its own URL write doesn't trigger the external + // reset below — that write is already reflected in the local overrides. + const selfUrlWrite = useRef(false); + const { DashboardLayout, WidgetFrame, @@ -179,6 +183,18 @@ export function DashboardView() { setActiveTab(null); }, [dashboard]); + // When the URL query string changes on its own — the viewer opens or navigates + // to the same dashboard with different params — drop the local overrides so the + // URL wins. Skip our own writes (handleFilterChange) so in-progress input + // (partial numbers, cleared fields) isn't clobbered. + useEffect(() => { + if (selfUrlWrite.current) { + selfUrlWrite.current = false; + return; + } + setFilters(null); + }, [searchParams]); + const handleExport = useCallback(async (format: DashboardExportFormat) => { if (!dashboard) return; setExporting(format); @@ -245,28 +261,28 @@ export function DashboardView() { // Reflect the change in the URL query string. const filter = dashboard.filters?.find((f) => f.name === filterName); - if (filter) { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - // Multi-select writes one repeated param per value (comma-safe). - if (filter.type === "select" && filter.multiple) { - next.delete(filterName); - if (Array.isArray(value)) { - for (const v of value) { - if (v != null && v !== "") next.append(filterName, String(v)); - } - } - } else { - const param = toParam(filter, value); - if (param == null) next.delete(filterName); - else next.set(filterName, param); - } - return next; - }, - { replace: true }, - ); + if (!filter) return; + + const next = new URLSearchParams(searchParams); + // Multi-select writes one repeated param per value (comma-safe). + if (filter.type === "select" && filter.multiple) { + next.delete(filterName); + if (Array.isArray(value)) { + for (const v of value) { + if (v != null && v !== "") next.append(filterName, String(v)); + } + } + } else { + const param = toParam(filter, value); + if (param == null) next.delete(filterName); + else next.set(filterName, param); } + + // Only write (and arm the self-write guard) when the query actually changes, + // so an identical write can't leave the guard stuck for a later external nav. + if (next.toString() === searchParams.toString()) return; + selfUrlWrite.current = true; + setSearchParams(next, { replace: true }); }; const filterBar = dashboard.filters ? ( From 2585970bc7b9f4b7ab19b242b2cb43a3f76aef62 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Tue, 28 Jul 2026 09:03:46 +0300 Subject: [PATCH 5/6] Sync dashboard filters with the URL via a projection effect Filter edits accumulate in local state and a single effect mirrors the whole set to the URL, so concurrent changes never drop a param and external URL changes re-seed the filters while in-progress input is preserved. --- frontend/src/components/DashboardView.tsx | 67 ++++++++++------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index d892377..9300f68 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -148,9 +148,8 @@ export function DashboardView() { const [filters, setFilters] = useState | null>(null); const activeFilters = filters ?? baseFilters; - // Set by handleFilterChange so its own URL write doesn't trigger the external - // reset below — that write is already reflected in the local overrides. - const selfUrlWrite = useRef(false); + // Last query string, so the reseed effect can skip our own writes. + const lastSelfWrite = useRef(null); const { DashboardLayout, @@ -177,21 +176,39 @@ export function DashboardView() { const [activeTab, setActiveTab] = useState(null); - // Reset local state when the dashboard definition changes (e.g. file edited on disk). + // Reset local overrides when the dashboard definition changes (e.g. file edited on disk). useEffect(() => { setFilters(null); setActiveTab(null); }, [dashboard]); - // When the URL query string changes on its own — the viewer opens or navigates - // to the same dashboard with different params — drop the local overrides so the - // URL wins. Skip our own writes (handleFilterChange) so in-progress input - // (partial numbers, cleared fields) isn't clobbered. + // Mirror local overrides to the URL. Writing the whole set + // stays correct when several filters change at once (e.g. a "reset all"). useEffect(() => { - if (selfUrlWrite.current) { - selfUrlWrite.current = false; - return; + if (filters == null || !dashboard) return; + const next = new URLSearchParams(searchParams); + for (const f of dashboard.filters ?? []) next.delete(f.name); + for (const f of dashboard.filters ?? []) { + const v = filters[f.name]; + if (f.type === "select" && f.multiple) { + if (Array.isArray(v)) { + for (const x of v) if (x != null && x !== "") next.append(f.name, String(x)); + } + } else { + const param = toParam(f, v); + if (param != null) next.set(f.name, param); + } } + const s = next.toString(); + if (s === searchParams.toString()) return; + lastSelfWrite.current = s; + setSearchParams(next, { replace: true }); + }, [filters]); + + // An external URL change (e.g. a shared link) re-seeds from the URL; our own + // writes are skipped so in-progress input survives. + useEffect(() => { + if (searchParams.toString() === lastSelfWrite.current) return; setFilters(null); }, [searchParams]); @@ -256,33 +273,9 @@ export function DashboardView() { const currentTab = activeTab ?? (hasTabs ? tabNames[0] : null); const handleFilterChange = (filterName: string, value: unknown) => { - // Seed from the currently active values so other filters are preserved. + // Accumulate locally; the projection effect writes the URL. Functional update + // so batched changes in one tick don't clobber each other. setFilters((prev) => ({ ...(prev ?? activeFilters ?? {}), [filterName]: value })); - - // Reflect the change in the URL query string. - const filter = dashboard.filters?.find((f) => f.name === filterName); - if (!filter) return; - - const next = new URLSearchParams(searchParams); - // Multi-select writes one repeated param per value (comma-safe). - if (filter.type === "select" && filter.multiple) { - next.delete(filterName); - if (Array.isArray(value)) { - for (const v of value) { - if (v != null && v !== "") next.append(filterName, String(v)); - } - } - } else { - const param = toParam(filter, value); - if (param == null) next.delete(filterName); - else next.set(filterName, param); - } - - // Only write (and arm the self-write guard) when the query actually changes, - // so an identical write can't leave the guard stuck for a later external nav. - if (next.toString() === searchParams.toString()) return; - selfUrlWrite.current = true; - setSearchParams(next, { replace: true }); }; const filterBar = dashboard.filters ? ( From aa03e1b765b858a913dd07ff250e0a68c883f60e Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Tue, 28 Jul 2026 09:11:44 +0300 Subject: [PATCH 6/6] Keep the self-write marker in sync so navigation is never skipped The projection effect now records the current filter query string even when the URL is already up to date, so an external navigation back to an older string is no longer mistaken for our own write. --- frontend/src/components/DashboardView.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index 9300f68..c0198da 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -182,8 +182,7 @@ export function DashboardView() { setActiveTab(null); }, [dashboard]); - // Mirror local overrides to the URL. Writing the whole set - // stays correct when several filters change at once (e.g. a "reset all"). + // Mirror all filters to the URL in one write (order-stable, concurrency-safe). useEffect(() => { if (filters == null || !dashboard) return; const next = new URLSearchParams(searchParams); @@ -199,14 +198,14 @@ export function DashboardView() { if (param != null) next.set(f.name, param); } } + // Record even when unchanged, so a later nav isn't taken for a self-write. const s = next.toString(); - if (s === searchParams.toString()) return; lastSelfWrite.current = s; - setSearchParams(next, { replace: true }); + if (s !== searchParams.toString()) setSearchParams(next, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- run only on filters change }, [filters]); - // An external URL change (e.g. a shared link) re-seeds from the URL; our own - // writes are skipped so in-progress input survives. + // External URL change (e.g. a shared link) re-seeds from the URL; our own writes are skipped. useEffect(() => { if (searchParams.toString() === lastSelfWrite.current) return; setFilters(null);