Skip to content

feat: save chart cell automatically and other improvements - #502

Merged
dmitriy-kostianetskiy merged 9 commits into
mainfrom
feat/charts-auto-save
Mar 30, 2026
Merged

dmitriy-kostianetskiy merged 9 commits into
mainfrom
feat/charts-auto-save

Conversation

@dmitriy-kostianetskiy

@dmitriy-kostianetskiy dmitriy-kostianetskiy commented Mar 27, 2026 •

Copy link
Copy Markdown
Collaborator
  • Chart saves automatically - save button removed
  • Icons added to type, x-axis, y-axis and color selectors
  • Minor refactoring
Screen.Recording.2026-03-27.at.13.35.20.mov

Summary by CodeRabbit

  • New Features
    • Added color, aggregation, chart-type, and field selectors with field-type icons.
    • New Vega chart header with data-source and edit toggle.
    • Added debounced hooks and a debounced value hook for rate-limited updates.
  • Improvements
    • Chart editor now applies configuration changes immediately and uses a settings toggle.
    • Unified chart builder with clearer defaults and improved cross-filter behavior.
  • Other
    • Utilities for constructing and reading Vega-Lite specs.

@coderabbitai

coderabbitai Bot commented Mar 27, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds selector/icon React components, a Vega‑Lite spec builder module, refactors Vega cell UI to use the new selectors and persist spec changes via debounced hooks, introduces debouncing utilities in UI package, and adds a Vega cell header component for data-source switching.

Changes

Cohort / File(s) Summary
Selector & Icon components
packages/cells/src/components/ColorSelector.tsx, packages/cells/src/components/FieldSelector.tsx, packages/cells/src/components/AggregationSelector.tsx, packages/cells/src/components/ChartTypeSelector.tsx, packages/cells/src/components/FieldTypeIcon.tsx
New controlled Select-based UI components and a FieldTypeIcon mapper; each selector exposes value/onValueChange and prevents default onCloseAutoFocus.
Vega spec utilities
packages/cells/src/vegaSpecBuilder.ts
New exported helpers readSpecValues, buildCrossFilterSpec, and buildFlatSpec for reading and constructing Vega‑Lite specs (supports single-layer and dual-layer cross-filter specs).
Vega components & header
packages/cells/src/components/VegaConfigPanel.tsx, packages/cells/src/components/VegaCellContent.tsx, packages/cells/src/components/VegaCellHeader.tsx
VegaConfigPanel replaced inline selectors with the new selector components and imports spec builders; VegaCellContent now uses a debounced localSpec and persists spec edits immediately; new VegaCellHeader handles data-source switching and updates cell draft data immutably.
UI hooks & re-exports
packages/ui/src/hooks/useDebounce.ts, packages/ui/src/hooks/useDebouncedCallback.ts, packages/ui/src/hooks/useDebouncedValue.ts, packages/ui/src/index.ts
Adds useDebounce, useDebouncedCallback, and useDebouncedValue hooks and re-exports them from the UI package index.
Misc wiring
packages/cells/src/components/...
Multiple components now consume the new hooks and spec builders; default color handling adjusted to use colorOptions[0]?.value when color is unset.

Sequence Diagram(s)

sequenceDiagram
  participant User as User
  participant Panel as VegaConfigPanel
  participant Selects as Selector Components
  participant Builder as vegaSpecBuilder
  participant Store as Cells Store
  participant Cell as VegaCellContent
  participant Chart as VegaLiteChart

  User->>Panel: open config / change selector
  Panel->>Selects: render/select value
  Selects-->>Panel: onValueChange(value)
  Panel->>Builder: buildFlatSpec / buildCrossFilterSpec(opts)
  Builder-->>Panel: vegaSpec
  Panel->>Store: updateCell(cellId, { data.vegaSpec: vegaSpec }) (debounced)
  Store-->>Cell: cell data updated
  Cell->>Chart: render with new vegaSpec
  Chart-->>User: updated visualization
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • ilyabo

Poem

🐰 I hopped through code with palette bright,
Selects and icons sing in light,
Builders stitch specs through day and night,
Debounced saves make edits right,
Charts bloom fast — a bunny's delight. 🎨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: save chart cell automatically and other improvements' accurately summarizes the main changes: automatic chart saving (removing the save button) and UI improvements (selector icons), with supporting refactoring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/charts-auto-save

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cells/src/components/VegaCellContent.tsx (1)

148-156: ⚠️ Potential issue | 🟠 Major

Add an accessible name to the icon-only settings button.

The button only renders an icon, so assistive tech has no descriptive label.

♿ Proposed fix
       <Button
         size="xs"
         variant="secondary"
         className="h-6"
+        aria-label={isEditing ? 'Close chart settings' : 'Open chart settings'}
         onClick={() => setIsEditing((prev) => !prev)}
         disabled={!hasDataSource}
       >
         <Settings className="h-4 w-4" />
       </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cells/src/components/VegaCellContent.tsx` around lines 148 - 156,
The Settings icon-only Button lacks an accessible name; update the Button in
VegaCellContent (the Button that toggles setIsEditing and uses hasDataSource) to
provide an accessible label for screen readers by adding an appropriate
aria-label (e.g., aria-label="Settings" or a localized string) or include
visually hidden text inside the Button; ensure the label clearly describes the
action (like "Edit settings") so assistive tech can announce it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cells/src/components/VegaCellContent.tsx`:
- Around line 131-140: handleSpecChange currently calls updateCell on every
keystroke/interaction; debounce the writes to prevent expensive update bursts by
wrapping the updateCell call in a debounced function (e.g., using
lodash.debounce or a custom setTimeout stored in a ref) inside the
VegaCellContent component. Create a debouncedUpdate (referencing updateCell and
id) and call that from handleSpecChange (keep handleSpecChange as a thin
wrapper), ensure the debounce is stable via useCallback/useRef and that you
cancel/flush the debounced timer on unmount (useEffect cleanup) so no stale
updates occur. Ensure dependencies include id and updateCell for correct
behavior.

---

Outside diff comments:
In `@packages/cells/src/components/VegaCellContent.tsx`:
- Around line 148-156: The Settings icon-only Button lacks an accessible name;
update the Button in VegaCellContent (the Button that toggles setIsEditing and
uses hasDataSource) to provide an accessible label for screen readers by adding
an appropriate aria-label (e.g., aria-label="Settings" or a localized string) or
include visually hidden text inside the Button; ensure the label clearly
describes the action (like "Edit settings") so assistive tech can announce it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7875071-209b-4b2e-bc7f-a77e0631003d

📥 Commits

Reviewing files that changed from the base of the PR and between 8d4b3fc and d465e03.

📒 Files selected for processing (6)
  • packages/cells/src/components/ColorSelector.tsx
  • packages/cells/src/components/FieldSelector.tsx
  • packages/cells/src/components/FieldTypeIcon.tsx
  • packages/cells/src/components/VegaCellContent.tsx
  • packages/cells/src/components/VegaConfigPanel.tsx
  • packages/cells/src/vegaSpecBuilder.ts

Comment thread packages/cells/src/components/VegaCellContent.tsx Outdated

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cells/src/components/AggregationSelector.tsx`:
- Around line 25-27: The current fallback for selectedAggregation silently
coerces unknown persisted values to aggregationOptions[0]; instead, when
aggregationOptions.find((opt) => opt.value === value) returns undefined,
preserve the original persisted value by creating a transient option (e.g. {
value, label: String(value) }) or set selectedAggregation to undefined/empty so
the selector displays the actual stored value and does not save a different
default; update the AggregationSelector logic that computes selectedAggregation
and ensure the selector's change/save handlers only write when the user picks a
new known option.

In `@packages/cells/src/components/ChartTypeSelector.tsx`:
- Around line 25-31: The code currently coerces any unrecognized incoming value
to chartTypeOptions[0] via selectedChartType, which will silently overwrite
persisted/unsupported marks; instead locate the matching option with
chartTypeOptions.find((opt) => opt.value === value) but do not default to
chartTypeOptions[0] — pass the original value through to the Select (and render
a fallback IconComponent or null when no match is found) so unknown persisted
values are preserved; ensure onValueChange still accepts and emits the new
value. Reference: selectedChartType, chartTypeOptions, value, IconComponent,
onValueChange, Select.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 826e1323-e51a-4fc9-91e9-1a24a8e20ef6

📥 Commits

Reviewing files that changed from the base of the PR and between d465e03 and 6c83017.

📒 Files selected for processing (3)
  • packages/cells/src/components/AggregationSelector.tsx
  • packages/cells/src/components/ChartTypeSelector.tsx
  • packages/cells/src/components/VegaConfigPanel.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cells/src/components/VegaConfigPanel.tsx

Comment on lines +25 to +27
const selectedAggregation =
aggregationOptions.find((opt) => opt.value === value) ??
aggregationOptions[0];

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.

⚠️ Potential issue | 🟠 Major

Do not silently coerce unknown aggregation values to sum.

On Line 25, the fallback to aggregationOptions[0] masks persisted values not in this local list, so the selector can display/save a different aggregate than what is currently stored in the spec.

Suggested fix
 const aggregationOptions = [
   {value: 'sum', label: 'Sum'},
   {value: 'mean', label: 'Mean'},
   {value: 'count', label: 'Count'},
 ] as const;
+const defaultAggregation = aggregationOptions[0].value;

 interface AggregationSelectorProps {
   value?: string;
   onValueChange: (value: string) => void;
 }

 export const AggregationSelector: React.FC<AggregationSelectorProps> = ({
   value,
   onValueChange,
 }) => {
-  const selectedAggregation =
-    aggregationOptions.find((opt) => opt.value === value) ??
-    aggregationOptions[0];
+  const options = React.useMemo(() => {
+    if (!value) return aggregationOptions;
+    return aggregationOptions.some((opt) => opt.value === value)
+      ? aggregationOptions
+      : [{value, label: value}, ...aggregationOptions];
+  }, [value]);
+
+  const selectedValue = value ?? defaultAggregation;

   return (
-    <Select value={selectedAggregation.value} onValueChange={onValueChange}>
+    <Select value={selectedValue} onValueChange={onValueChange}>
       <SelectTrigger className="h-8 text-xs">
         <SelectValue />
       </SelectTrigger>
       <SelectContent onCloseAutoFocus={(e) => e.preventDefault()}>
-        {aggregationOptions.map((option) => (
+        {options.map((option) => (
           <SelectItem key={option.value} value={option.value}>
             {option.label}
           </SelectItem>
         ))}
       </SelectContent>
     </Select>
   );
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cells/src/components/AggregationSelector.tsx` around lines 25 - 27,
The current fallback for selectedAggregation silently coerces unknown persisted
values to aggregationOptions[0]; instead, when aggregationOptions.find((opt) =>
opt.value === value) returns undefined, preserve the original persisted value by
creating a transient option (e.g. { value, label: String(value) }) or set
selectedAggregation to undefined/empty so the selector displays the actual
stored value and does not save a different default; update the
AggregationSelector logic that computes selectedAggregation and ensure the
selector's change/save handlers only write when the user picks a new known
option.

Comment on lines +25 to +31
const selectedChartType =
chartTypeOptions.find((opt) => opt.value === value) ?? chartTypeOptions[0];

const IconComponent = selectedChartType.icon;

return (
<Select value={selectedChartType.value} onValueChange={onValueChange}>

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.

⚠️ Potential issue | 🟠 Major

Avoid coercing unknown persisted marks to bar in the controlled value.

Line 25 and Line 31 force any unrecognized value to bar. With the rebuild flow in packages/cells/src/components/VegaConfigPanel.tsx (Line 68–74), that can silently overwrite previously saved unsupported marks after subsequent config edits.

💡 Proposed fix
-  const selectedChartType =
-    chartTypeOptions.find((opt) => opt.value === value) ?? chartTypeOptions[0];
-
-  const IconComponent = selectedChartType.icon;
+  const selectedChartType = chartTypeOptions.find((opt) => opt.value === value);
+  const selectValue =
+    value === undefined ? chartTypeOptions[0].value : selectedChartType?.value;
+  const IconComponent = selectedChartType?.icon;

   return (
-    <Select value={selectedChartType.value} onValueChange={onValueChange}>
+    <Select value={selectValue} onValueChange={onValueChange}>
       <SelectTrigger className="h-8 text-xs">
         <SelectValue placeholder="Select chart type">
-          <div className="flex items-center gap-2">
-            <IconComponent className="h-4 w-4" />
-            <span>{selectedChartType.label}</span>
-          </div>
+          {selectedChartType ? (
+            <div className="flex items-center gap-2">
+              <IconComponent className="h-4 w-4" />
+              <span>{selectedChartType.label}</span>
+            </div>
+          ) : null}
         </SelectValue>
       </SelectTrigger>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cells/src/components/ChartTypeSelector.tsx` around lines 25 - 31,
The code currently coerces any unrecognized incoming value to
chartTypeOptions[0] via selectedChartType, which will silently overwrite
persisted/unsupported marks; instead locate the matching option with
chartTypeOptions.find((opt) => opt.value === value) but do not default to
chartTypeOptions[0] — pass the original value through to the Select (and render
a fallback IconComponent or null when no match is found) so unknown persisted
values are preserved; ensure onValueChange still accepts and emits the new
value. Reference: selectedChartType, chartTypeOptions, value, IconComponent,
onValueChange, Select.

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cells/src/components/VegaCellContent.tsx (1)

151-159: ⚠️ Potential issue | 🟠 Major

Add an accessible name to the icon-only settings button.

The button currently has no text/ARIA label, so assistive tech users won’t get its purpose.

♿ Suggested fix
       <Button
         size="xs"
         variant="secondary"
         className="h-6"
         onClick={() => setIsEditing((prev) => !prev)}
         disabled={!hasDataSource}
+        aria-label={isEditing ? 'Close chart settings' : 'Open chart settings'}
+        title={isEditing ? 'Close chart settings' : 'Open chart settings'}
       >
         <Settings className="h-4 w-4" />
       </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cells/src/components/VegaCellContent.tsx` around lines 151 - 159,
The settings Button (the icon-only Button that toggles setIsEditing and is
disabled by hasDataSource) lacks an accessible name; add an aria-label (or a
visually-hidden text child) to the Button so screen readers know its purpose
(e.g., aria-label="Cell settings" or similar) and ensure the label accurately
reflects the action toggled by setIsEditing; keep the Settings icon as-is and do
not remove the disabled prop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cells/src/components/VegaCellContent.tsx`:
- Around line 45-49: VegaCellContent currently reads vegaSpec from
cell.data.vegaSpec and calls updateCell via a debounced path which can cause
stale reads to overwrite recent rapid edits; fix by keeping a local live spec
state (e.g., useState called localSpec initialized from cell.data.vegaSpec or
default) and update localSpec synchronously on control changes, then call the
debounced updateCell only to persist localSpec to the store; update the handlers
that currently reference vegaSpec and call updateCell (symbols: vegaSpec,
cell.data.vegaSpec, updateCell, VegaCellContent) to use localSpec for immediate
UI changes and the debounced persistence for store updates, and apply this same
local-state + debounce-only-persist pattern to the other spots noted where
updateCell reads the store spec.

---

Outside diff comments:
In `@packages/cells/src/components/VegaCellContent.tsx`:
- Around line 151-159: The settings Button (the icon-only Button that toggles
setIsEditing and is disabled by hasDataSource) lacks an accessible name; add an
aria-label (or a visually-hidden text child) to the Button so screen readers
know its purpose (e.g., aria-label="Cell settings" or similar) and ensure the
label accurately reflects the action toggled by setIsEditing; keep the Settings
icon as-is and do not remove the disabled prop.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b96d816-2a22-40df-b0cc-09c7e3e3fbfb

📥 Commits

Reviewing files that changed from the base of the PR and between 6c83017 and 91c0e8d.

📒 Files selected for processing (4)
  • packages/cells/src/components/VegaCellContent.tsx
  • packages/ui/src/hooks/useDebounce.ts
  • packages/ui/src/hooks/useDebouncedCallback.ts
  • packages/ui/src/index.ts

Comment thread packages/cells/src/components/VegaCellContent.tsx Outdated

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/cells/src/components/VegaCellHeader.tsx (1)

29-48: Consider wrapping handleDataSourceChange in useCallback.

This handler is recreated on every render since it's not memoized. While functionally correct, this could cause unnecessary re-renders in CellSourceSelector if it's memoized or uses the callback in its own dependency arrays.

♻️ Proposed fix
+import {useCallback} from 'react';
...
-  const handleDataSourceChange = (value: string) => {
+  const handleDataSourceChange = useCallback((value: string) => {
     updateCell(cellId, (c) =>
       produce(c, (draft) => {
         if (draft.type !== 'vega') {
           return;
         }

         const cellIdFromValue = fromDataSourceCell(value);
         const tableRef = fromDataSourceTable(value);

         if (cellIdFromValue) {
           draft.data.sqlId = cellIdFromValue;
           delete draft.data.tableRef;
         } else if (tableRef) {
           draft.data.tableRef = tableRef;
           delete draft.data.sqlId;
         }
       }),
     );
-  };
+  }, [cellId, updateCell]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cells/src/components/VegaCellHeader.tsx` around lines 29 - 48, The
handler handleDataSourceChange is recreated each render; wrap it in useCallback
to stabilize its identity for downstream memoized components like
CellSourceSelector. Import React's useCallback and change const
handleDataSourceChange = (value: string) => { ... } to const
handleDataSourceChange = useCallback((value: string) => { updateCell(cellId, (c)
=> produce(c, (draft) => { /* same body using fromDataSourceCell and
fromDataSourceTable, update draft.data.sqlId/tableRef */ })); }, [updateCell,
cellId]); ensuring the dependency array includes any props or functions used
(e.g., updateCell and cellId).
packages/ui/src/hooks/useDebouncedValue.ts (1)

33-36: Potential re-render loop if externalValue is a new object reference each render.

The sync effect depends on externalValue directly. If the consumer passes an inline object/array (e.g., cell.data.vegaSpec ?? { ... }), every render creates a new reference, triggering the effect repeatedly even when values are semantically equal.

Consumers should memoize externalValue or this hook should optionally accept a custom equality check. The current usage in VegaCellContent appears safe since cell.data.vegaSpec comes from the store, but document this requirement to prevent misuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/hooks/useDebouncedValue.ts` around lines 33 - 36, The effect
in useDebouncedValue synchronizes localValue every time externalValue reference
changes, causing potential re-render loops when callers pass inline
objects/arrays; update useDebouncedValue to accept an optional equality
comparator (e.g., isEqual?: (a,b)=>boolean) and change the sync effect to
compare previousExternalRef.current to externalValue using that comparator
before calling setLocalValue, storing the last external value in a ref
(previousExternalRef) to avoid blind updates; provide a sensible default
comparator (shallow/deep via JSON.stringify or import isEqual) and update the
hook's TypeScript signature and JSDoc so callers (like VegaCellContent) either
memoize externalValue or pass a comparator.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/ui/src/hooks/useDebouncedValue.ts`:
- Around line 38-51: The debounced onPersist call from useDebouncedCallback
(used as debouncedPersist in useDebouncedValue) is cancelled on unmount which
can lose recent edits; modify useDebouncedValue to either (a) accept an option
like flushOnUnmount or expose the debounced flush function and call it in a
cleanup, or (b) return a third element flush() alongside [localValue, setValue]
that calls debouncedPersist.flush(); update the hook to reference
useDebouncedCallback, debouncedPersist, setValue and onPersist so callers can
flush pending persistence in their own cleanup or have the hook auto-flush on
unmount when the option is enabled.

---

Nitpick comments:
In `@packages/cells/src/components/VegaCellHeader.tsx`:
- Around line 29-48: The handler handleDataSourceChange is recreated each
render; wrap it in useCallback to stabilize its identity for downstream memoized
components like CellSourceSelector. Import React's useCallback and change const
handleDataSourceChange = (value: string) => { ... } to const
handleDataSourceChange = useCallback((value: string) => { updateCell(cellId, (c)
=> produce(c, (draft) => { /* same body using fromDataSourceCell and
fromDataSourceTable, update draft.data.sqlId/tableRef */ })); }, [updateCell,
cellId]); ensuring the dependency array includes any props or functions used
(e.g., updateCell and cellId).

In `@packages/ui/src/hooks/useDebouncedValue.ts`:
- Around line 33-36: The effect in useDebouncedValue synchronizes localValue
every time externalValue reference changes, causing potential re-render loops
when callers pass inline objects/arrays; update useDebouncedValue to accept an
optional equality comparator (e.g., isEqual?: (a,b)=>boolean) and change the
sync effect to compare previousExternalRef.current to externalValue using that
comparator before calling setLocalValue, storing the last external value in a
ref (previousExternalRef) to avoid blind updates; provide a sensible default
comparator (shallow/deep via JSON.stringify or import isEqual) and update the
hook's TypeScript signature and JSDoc so callers (like VegaCellContent) either
memoize externalValue or pass a comparator.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0356a1a4-3435-4336-8e4e-5571d5377e98

📥 Commits

Reviewing files that changed from the base of the PR and between 91c0e8d and 309411a.

📒 Files selected for processing (4)
  • packages/cells/src/components/VegaCellContent.tsx
  • packages/cells/src/components/VegaCellHeader.tsx
  • packages/ui/src/hooks/useDebouncedValue.ts
  • packages/ui/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ui/src/index.ts

Comment on lines +38 to +51
// Debounced persistence
const debouncedPersist = useDebouncedCallback(onPersist, debounceMs);

// Combined setter: immediate local update + debounced persist
const setValue = useCallback(
(value: T) => {
setLocalValue(value);
debouncedPersist(value);
},
[debouncedPersist],
);

return [localValue, setValue];
}

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.

⚠️ Potential issue | 🟠 Major

Pending changes may be lost on unmount.

Per the cleanup logic in useDebouncedCallback (lines 58-65 in that file), when the component unmounts before the debounce delay expires, the pending onPersist call is cancelled rather than flushed. This means edits made within the final 300ms window before unmount will be permanently lost.

Consider adding an optional flush capability or documenting this limitation clearly. Alternatively, expose a flush() method that consumers can call in their own cleanup effects when data loss is unacceptable.

💡 Potential fix: Add flush-on-unmount option
 export function useDebouncedValue<T>(
   externalValue: T,
   onPersist: (value: T) => void,
   debounceMs = 300,
+  { flushOnUnmount = false }: { flushOnUnmount?: boolean } = {},
 ): [T, (value: T) => void] {
   // Local state for immediate UI updates
   const [localValue, setLocalValue] = useState<T>(externalValue);
+  const localValueRef = useRef(localValue);
+  const hasPendingRef = useRef(false);

   // Sync local state when external value changes
   useEffect(() => {
     setLocalValue(externalValue);
   }, [externalValue]);

   // Debounced persistence
   const debouncedPersist = useDebouncedCallback(onPersist, debounceMs);

   // Combined setter: immediate local update + debounced persist
   const setValue = useCallback(
     (value: T) => {
       setLocalValue(value);
+      localValueRef.current = value;
+      hasPendingRef.current = true;
       debouncedPersist(value);
     },
     [debouncedPersist],
   );

+  // Flush on unmount if enabled
+  useEffect(() => {
+    return () => {
+      if (flushOnUnmount && hasPendingRef.current) {
+        onPersist(localValueRef.current);
+      }
+    };
+  }, [flushOnUnmount, onPersist]);

   return [localValue, setValue];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/hooks/useDebouncedValue.ts` around lines 38 - 51, The
debounced onPersist call from useDebouncedCallback (used as debouncedPersist in
useDebouncedValue) is cancelled on unmount which can lose recent edits; modify
useDebouncedValue to either (a) accept an option like flushOnUnmount or expose
the debounced flush function and call it in a cleanup, or (b) return a third
element flush() alongside [localValue, setValue] that calls
debouncedPersist.flush(); update the hook to reference useDebouncedCallback,
debouncedPersist, setValue and onPersist so callers can flush pending
persistence in their own cleanup or have the hook auto-flush on unmount when the
option is enabled.

@dmitriy-kostianetskiy
dmitriy-kostianetskiy merged commit f11641a into main Mar 30, 2026
4 checks passed
@dmitriy-kostianetskiy
dmitriy-kostianetskiy deleted the feat/charts-auto-save branch March 30, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants