Skip to content

feat: Mosaic chart builders - #473

Merged
ilyabo merged 12 commits into
mainfrom
mosaic-chart-builders
Mar 18, 2026
Merged

ilyabo merged 12 commits into
mainfrom
mosaic-chart-builders

Conversation

@ilyabo

@ilyabo ilyabo commented Mar 16, 2026 •

Copy link
Copy Markdown
Collaborator
deckgl-mosaic-charts2.mp4

Summary by CodeRabbit

  • New Features

    • Dynamic mosaic grid: add/edit/remove charts with per-chart inline editors and a chart builder dialog.
    • Guided chart builder UI with field picker and multiple predefined chart templates.
    • Composite chart/editor components for cohesive editing, preview, and actions.
  • Improvements

    • Live spec validation with debounced parsing and cached schema loading.
    • Rendering/editor optimizations and improved cross-filtering support across charts.

ilyabo added 3 commits March 16, 2026 11:55
Signed-off-by: Ilya Boyandin <[email protected]>
@coderabbitai

coderabbitai Bot commented Mar 16, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a Mosaic chart builder and editable spec editor: new chart-builder types, dialog, field selector, default builders, editor hook/context, CodeMirror-based spec editor with optional schema validation, Mosaic editor UI components, VgPlotChart params support, exports, and example integration replacing static filter plots.

Changes

Cohort / File(s) Summary
Example integration
examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx, examples/deckgl-mosaic/src/components/filters/filterPlots.ts
Replace static per-chart plots with declarative ChartConfig objects, dynamic per-chart list, Add Chart flow using ChartBuilderDialog, and wiring of chart edit/remove and cross-filter params.
Chart builder UI & types
packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx, packages/mosaic/src/chart-builders/ChartBuilderContent.tsx, packages/mosaic/src/chart-builders/FieldSelectorInput.tsx, packages/mosaic/src/chart-builders/builders.ts, packages/mosaic/src/chart-builders/types.ts
Add chart-builder templates, field types, UI (content + dialog), searchable field selector, and factory for default builders.
Editor core, types & hook
packages/mosaic/src/editor/types.ts, packages/mosaic/src/editor/useMosaicChartEditor.ts, packages/mosaic/src/editor/MosaicEditorContext.tsx
New editor types, context hook, and useMosaicChartEditor implementing debounced JSON parsing, apply/cancel/reset workflow, and last-valid-spec tracking.
Editor UI components
packages/mosaic/src/editor/MosaicChartContainer.tsx, packages/mosaic/src/editor/MosaicChartDisplay.tsx, packages/mosaic/src/editor/MosaicSpecEditorPanel.tsx, packages/mosaic/src/editor/MosaicChartEditorActions.tsx, packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx, packages/mosaic/src/editor/mosaicSchema.ts
New container/provider, display wrapper, CodeMirror-based spec editor panel (with validation and error banner), editor actions bar, schema loader (lazy cached), and a Mosaic-specialized CodeMirror wrapper with optional schema validation.
VgPlotChart changes & package exports
packages/mosaic/src/VgPlotChart.tsx, packages/mosaic/src/index.ts, packages/mosaic/package.json
Memoize VgPlotChart with custom comparator and optional params injection; add package deps; re-export new MosaicChart, MosaicChartBuilder, editor and builder APIs and types.
Mosaic composite modules
packages/mosaic/src/MosaicChart.ts, packages/mosaic/src/MosaicChartBuilder.ts
New composite namespaces MosaicChart and MosaicChartBuilder that group related components/exports.

Sequence Diagram

sequenceDiagram
    participant User
    participant Builder as Chart Builder\n(Dialog)
    participant Container as MosaicChart\n(Container)
    participant Editor as Spec Editor\n(MosaicCodeMirrorEditor)
    participant Display as Chart Display\n(MosaicChartDisplay)

    User->>Builder: Click "Add Chart"
    Builder->>Builder: Select builder & fill fields
    Builder->>User: onCreateChart(spec, title)
    Builder->>Container: Create chart instance (spec, params)
    Container->>Display: Provide initialSpec / params
    User->>Editor: Edit JSON spec
    Editor->>Container: setEditedSpec (debounced)
    Container->>Display: parsedSpec (live preview)
    User->>Container: Click "Apply"
    Container->>Display: applyChanges -> update applied spec
    Display->>Display: Re-render VgPlotChart
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • bdjulbic
  • mihajlo-stojkovic

Poem

🐰
I nibble specs and stitch a chart,
Builders bloom from a tiny start,
Fields hop in with a curious nose,
Debounced lines tidy as it grows,
New mosaics spring — the rabbit knows.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: Mosaic chart builders' accurately summarizes the main change—introducing new chart builder components and utilities for the Mosaic library. It is concise, clear, and directly reflects the primary feature addition.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 mosaic-chart-builders
📝 Coding Plan
  • Generate coding plan for human review comments

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.

ilyabo added 2 commits March 16, 2026 12:50
Signed-off-by: Ilya Boyandin <[email protected]>
@ilyabo
ilyabo marked this pull request as ready for review March 16, 2026 11:54
Signed-off-by: Ilya Boyandin <[email protected]>

@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: 4

🧹 Nitpick comments (13)
packages/mosaic/src/editor/useMosaicChartEditor.ts (2)

143-146: useEffect without dependency array updates ref on every render.

This useEffect runs on every render to keep onSpecChangeRef current. While functional, consider omitting the useEffect entirely and assigning directly since refs don't trigger re-renders:

♻️ Simpler pattern
 const onSpecChangeRef = useRef(onSpecChange);
-useEffect(() => {
-  onSpecChangeRef.current = onSpecChange;
-});
+onSpecChangeRef.current = onSpecChange;

Note: Both patterns work; the direct assignment is slightly more concise.

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

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts` around lines 143 - 146,
The current useEffect updates onSpecChangeRef.current on every render; replace
that pattern by removing the useEffect and directly assigning the prop into the
ref (keep the existing const onSpecChangeRef = useRef(onSpecChange)) by setting
onSpecChangeRef.current = onSpecChange in the component body so the ref stays
current without an effect—refer to onSpecChangeRef, useRef and onSpecChange to
locate the change.

152-187: Complex nested setState in applyChanges.

The applyChanges callback has nested setDebouncedParsed and setEditorState calls, which can be difficult to reason about. The logic is correct, but consider extracting the dirty-check into a separate utility for clarity.

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

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts` around lines 152 - 187,
The applyChanges function contains a nested setDebouncedParsed -> setEditorState
with an inline IIFE dirty-check; extract that dirty-check into a small pure
utility (e.g., isSpecDirty(editedSpecString, appliedSpecString)) and replace the
inline try/catch IIFE with a call to that utility inside setEditorState, keeping
the same behavior for calling onSpecChangeRef.current and updating
appliedSpecString/prevInitialSpecString; ensure the utility handles JSON.parse
errors the same way and update imports/refs so applyChanges, setDebouncedParsed,
setEditorState and onSpecChangeRef use the new function.
packages/mosaic/src/editor/mosaicSchema.ts (1)

26-48: Consider adding a timeout to the fetch request.

The fetch has no timeout configured, which could cause indefinite hangs if the CDN is slow or unresponsive. Consider using AbortSignal.timeout() for resilience.

♻️ Proposed fix
 loadingPromise = (async () => {
   try {
-    const response = await fetch(MOSAIC_SCHEMA_URL);
+    const response = await fetch(MOSAIC_SCHEMA_URL, {
+      signal: AbortSignal.timeout(10000), // 10 second timeout
+    });
     if (!response.ok) {
       throw new Error(`HTTP ${response.status}: ${response.statusText}`);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/editor/mosaicSchema.ts` around lines 26 - 48, The fetch
of MOSAIC_SCHEMA_URL inside the async IIFE that assigns loadingPromise should
use an AbortSignal timeout to avoid hanging: create an AbortController (or use
AbortSignal.timeout if available), pass its signal to fetch(MOSAIC_SCHEMA_URL, {
signal }), and set a timeout to call controller.abort() (or rely on
AbortSignal.timeout) before awaiting the response; ensure you catch the abort
error in the existing catch block and still set cachedSchema fallback and clear
loadingPromise in the finally block—update references in this function to use
the controller/signal and avoid changing the existing cachedSchema/error
handling logic.
packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx (1)

33-41: Schema state is not cleared when validation is disabled.

If enableSchemaValidation transitions from true to false, the previously loaded schema remains in state but is unused. Consider clearing it to avoid confusion and potential memory overhead.

♻️ Proposed fix
 useEffect(() => {
   if (enableSchemaValidation) {
     loadMosaicSchema().then((loadedSchema) => {
       if (loadedSchema) {
         setSchema(loadedSchema);
       }
     });
+  } else {
+    setSchema(null);
   }
 }, [enableSchemaValidation]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx` around lines 33 - 41,
When enableSchemaValidation toggles off the existing schema in state should be
cleared to avoid keeping unused data; update the useEffect that depends on
enableSchemaValidation (the one calling loadMosaicSchema) so that when
enableSchemaValidation is false you call setSchema(null or undefined) (and keep
the existing branch that loads and sets the schema when true), and ensure
loadMosaicSchema and setSchema usage remain consistent with the chosen empty
value.
packages/mosaic/src/VgPlotChart.tsx (1)

36-53: Async effect lacks cleanup handling for unmount during render.

If the component unmounts while parseSpec or astToDOM is in progress, containerRef.current may be null when replaceChildren is called (line 50 guards with ?., which is good). However, the rendered element is still created unnecessarily. Consider using an AbortSignal or cleanup flag for better resource management.

♻️ Proposed fix with cleanup
 useEffect(() => {
+  let cancelled = false;
   (async () => {
     if (containerRef.current) {
       let element: HTMLElement | SVGSVGElement;
       if (isPlotProps(props)) {
         element = props.plot;
       } else if (isSpecProps(props)) {
         const ast = await parseSpec(props.spec);
+        if (cancelled) return;
         const options = props.params ? {params: props.params} : undefined;
         element = (await astToDOM(ast, options)).element;
+        if (cancelled) return;
       } else {
         element = document.createElement('div');
         element.innerHTML = 'Error: Invalid props provided to VgPlotChart';
       }
       containerRef.current?.replaceChildren(element);
     }
   })();
+  return () => { cancelled = true; };
 }, [props]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/VgPlotChart.tsx` around lines 36 - 53, The async effect
in VgPlotChart should avoid doing work after unmount: inside the useEffect that
calls parseSpec and astToDOM, add a cleanup flag (e.g., let cancelled = false)
and, if available, pass an AbortSignal to parseSpec/astToDOM; after each await
check the flag (and signal) before creating the element or calling
containerRef.current.replaceChildren. Ensure the cleanup function sets cancelled
= true (and aborts the signal if used) so no DOM creation or expensive work
proceeds after unmount; reference the useEffect block, parseSpec, astToDOM,
containerRef, and replaceChildren when applying this change.
examples/deckgl-mosaic/src/components/filters/filterPlots.ts (2)

73-73: Unusual negative left margin.

left: -15 is atypical. If intentional (e.g., to compensate for hidden y-axis), a brief comment would help future maintainers understand the reasoning.

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

In `@examples/deckgl-mosaic/src/components/filters/filterPlots.ts` at line 73, The
left margin in the margins object is set to an unusual negative value (margins:
{left: -15, right: 15, top: 10, bottom: 30}); if this was intentional (e.g., to
offset a hidden y-axis or align tiles), add a short inline comment next to the
left property describing the reason and the expected visual effect, otherwise
change left to a non-negative value (e.g., 0 or a small positive number) and
verify layout in the component that uses margins (the margins object in
filterPlots.ts).

6-10: Consider using Zod schema for ChartConfig if this becomes a reusable pattern.

The ChartConfig interface is clean for this example. If chart configurations become user-editable or serialized, consider adding a Zod schema for runtime validation. As per coding guidelines for **/*.{ts,tsx}: "Use Zod schemas for all configuration objects."

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

In `@examples/deckgl-mosaic/src/components/filters/filterPlots.ts` around lines 6
- 10, ChartConfig is currently a TypeScript interface only; add a Zod runtime
schema to validate ChartConfig when configurations become user-editable or
serialized. Create and export a Zod schema named ChartConfigSchema (e.g., using
z.object with id: z.string(), title: z.string(), spec: appropriate schema or
z.any() if Spec can't be expressed) and use it where configs are parsed/loaded
to parse/validate inputs; update any factory/loader code that consumes
ChartConfig to call ChartConfigSchema.parse() (or safeParse) so runtime
validation is enforced.
packages/mosaic/src/chart-builders/types.ts (1)

7-16: Consider adding Zod schema for runtime validation.

Per coding guidelines, Zod schemas are recommended for configuration objects. While these interfaces work well for compile-time type checking, adding corresponding Zod schemas would enable runtime validation when processing user-provided or serialized builder configurations.

♻️ Example Zod schema addition
import {z} from 'zod';

export const MosaicChartBuilderFieldSchema = z.object({
  key: z.string(),
  label: z.string(),
  required: z.boolean().optional(),
  types: z.array(z.string()).optional(),
});

export type MosaicChartBuilderField = z.infer<typeof MosaicChartBuilderFieldSchema>;

As per coding guidelines: "Use Zod schemas for all configuration objects and infer types with z.infer<typeof ConfigSchema>"

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

In `@packages/mosaic/src/chart-builders/types.ts` around lines 7 - 16, Add a Zod
schema for the MosaicChartBuilderField interface so configurations can be
runtime-validated: create a MosaicChartBuilderFieldSchema using z.object(...)
that requires key and label as strings and marks required as optional boolean
and types as optional array of strings, then export type MosaicChartBuilderField
= z.infer<typeof MosaicChartBuilderFieldSchema>; update any code that imports
the interface to use the inferred type or accept the schema for validation where
builder configs are parsed.
packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx (3)

60-64: Hardcoded animation delay is fragile.

The setTimeout(..., 200) assumes the dialog close animation takes exactly 200ms. If the animation duration changes, the reset could happen too early (visual glitch) or too late (wasted delay).

Consider using the dialog's onAnimationEnd callback or onOpenChange with a flag, or extracting 200 as a named constant.

♻️ Extract as named constant
+const DIALOG_CLOSE_ANIMATION_MS = 200;
+
 const handleClose = useCallback(() => {
   onOpenChange(false);
   // Reset after animation
-  setTimeout(handleReset, 200);
+  setTimeout(handleReset, DIALOG_CLOSE_ANIMATION_MS);
 }, [onOpenChange, handleReset]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx` around lines 60 -
64, The hardcoded 200ms delay in handleClose (setTimeout(handleReset, 200)) is
fragile; replace it by either listening for the dialog's animation end event or
by extracting the delay into a clearly named constant and referencing that
constant, and then call handleReset only after the animation completes; update
the handler around onOpenChange and handleReset in ChartBuilderDialog.tsx
(function handleClose) to trigger reset via an onAnimationEnd/onTransitionEnd
callback on the dialog component or use a named constant like
DIALOG_CLOSE_ANIMATION_MS instead of magic number 200 so the timing remains
correct and maintainable.

75-80: Implicit required field behavior.

The condition f.required !== false treats required: undefined as required. This is likely intentional (default to required), but consider documenting this behavior in the MosaicChartBuilderField interface or using an explicit default.

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

In `@packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx` around lines 75 -
80, The current canCreate logic treats undefined required as required
implicitly; update the check to make that explicit and/or document the default:
replace the filter predicate with (f.required ?? true) so the code reads
selectedBuilder.fields.filter(f => (f.required ?? true)).every(f =>
fieldValues[f.key]); also add a short comment or JSDoc on the
MosaicChartBuilderField interface indicating that required defaults to true to
make the behavior explicit (referencing selectedBuilder, canCreate, and
MosaicChartBuilderField).

156-167: Consider adding a Cancel button in step 2.

When in field configuration (step 2), users can only go "Back" or "Create". A "Cancel" button would provide a clearer way to close the dialog entirely without completing the flow.

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

In `@packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx` around lines 156 -
167, Add a Cancel button in the DialogFooter when in step 2 (i.e., when
selectedBuilder is truthy) that closes the dialog without creating anything;
place it alongside the existing Back and Create buttons and wire it to the
dialog close handler (use the component's existing onClose/close handler or add
one if missing), ensuring it does not affect canCreate logic and that
handleReset remains attached to the Back button and handleCreate to Create.
packages/mosaic/src/chart-builders/FieldSelectorInput.tsx (1)

36-42: Type filtering uses partial matching.

The includes() check will match partial type names (e.g., a filter for 'INT' would match 'INTEGER', 'BIGINT', and 'SMALLINT'). This is likely intentional for flexibility, but if exact matching is needed, consider using equality or a more precise pattern.

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

In `@packages/mosaic/src/chart-builders/FieldSelectorInput.tsx` around lines 36 -
42, The current filtering for filteredColumns uses partial matching via
col.type.toUpperCase().includes(t.toUpperCase()), which allows e.g. "INT" to
match "INTEGER"; change the comparison in the FieldSelectorInput filtering logic
to perform an exact case-insensitive match (e.g., compare col.type.toUpperCase()
=== t.toUpperCase()) or switch to a stricter pattern (anchored regex) so
field.types items only match exact column types; update the filter expression
around field.types, columns, and col.type accordingly.
packages/mosaic/src/chart-builders/builders.ts (1)

20-93: Consider consolidating similar builders.

countPlotBuilder and histogramBuilder are nearly identical—the only differences are maxbins (25 vs 40), yLabel (null vs 'Count'), and left margin (0 vs 40). Consider a shared factory function to reduce duplication.

♻️ Example refactor using a shared factory
const createHistogramBuilder = (
  id: string,
  description: string,
  maxbins: number,
  yLabel: string | null,
  leftMargin: number,
): MosaicChartBuilder => ({
  id,
  icon: id === 'count-plot' ? BarChartHorizontal : BarChart3,
  description,
  fields: [{key: 'field', label: 'Field', required: true}],
  createSpec: (tableName, {field}): Spec => ({
    // ... shared implementation with parameters
  }) as Spec,
});

const countPlotBuilder = createHistogramBuilder('count-plot', 'Create a count plot of a field', 25, null, 0);
const histogramBuilder = createHistogramBuilder('histogram', 'Create a histogram of a field', 40, 'Count', 40);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/chart-builders/builders.ts` around lines 20 - 93, Both
builders (countPlotBuilder and histogramBuilder) duplicate the same createSpec
logic; extract a shared factory (e.g., createHistogramBuilder) that accepts
parameters for id, icon, description, maxbins, yLabel, and leftMargin and
returns a MosaicChartBuilder; inside the factory implement the common plot array
(reusing BG_COLOR, FG_COLOR, inset, params, and select names) and use it to
construct countPlotBuilder and histogramBuilder by passing (25, null, 0) and
(40, 'Count', 40) respectively, keeping fields and createSpec signatures
identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx`:
- Around line 116-121: The <div> in FiltersPanel currently carries invalid
React/HTML props (type="multiple", value, onValueChange) leftover from an
Accordion refactor; remove those props from the div (and any related unused
state bindings like expandedCharts/setExpandedCharts if they're now unused) or
replace the div with the proper Accordion component from `@sqlrooms/ui` and wire
expandedCharts/setExpandedCharts into that Accordion's API instead of using HTML
attributes on a div. Ensure all references to expandedCharts and
setExpandedCharts are updated or removed to avoid unused-state warnings.
- Around line 80-84: The handler handleCreateChart references setExpandedCharts
before expandedCharts state is declared, causing a temporal dead zone; move the
expandedCharts state declaration (and any state initializers like charts) so
that useState for expandedCharts and charts (using defaultChartConfigs as the
initial value for charts instead of charts) appears above the definition of
handleCreateChart, and update handleCreateChart to use the now-in-scope
setExpandedCharts and setCharts functions.

In `@packages/mosaic/package.json`:
- Line 28: The lucide-react dependency in package.json currently uses an overly
broad range ("lucide-react": ">=0.300.0"); change it to match the workspace and
`@sqlrooms/ui` version range (for example "^0.556.0") to prevent resolution
drift—update the "lucide-react" entry in packages/mosaic/package.json
accordingly so it aligns with the version used by `@sqlrooms/ui` and other
workspace packages.

In `@packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx`:
- Around line 12-16: The JSDoc for the prop enableSchemaValidation claims
`@default` true but the implementation sets the default to false; update one of
them so they match: either change the JSDoc `@default` to false or change the
actual default in the MosaicCodeMirrorEditor component (where
enableSchemaValidation is set) to true. Locate the prop/interface declaration
named enableSchemaValidation and the place in the MosaicCodeMirrorEditor
component that assigns its default value and make them consistent.

---

Nitpick comments:
In `@examples/deckgl-mosaic/src/components/filters/filterPlots.ts`:
- Line 73: The left margin in the margins object is set to an unusual negative
value (margins: {left: -15, right: 15, top: 10, bottom: 30}); if this was
intentional (e.g., to offset a hidden y-axis or align tiles), add a short inline
comment next to the left property describing the reason and the expected visual
effect, otherwise change left to a non-negative value (e.g., 0 or a small
positive number) and verify layout in the component that uses margins (the
margins object in filterPlots.ts).
- Around line 6-10: ChartConfig is currently a TypeScript interface only; add a
Zod runtime schema to validate ChartConfig when configurations become
user-editable or serialized. Create and export a Zod schema named
ChartConfigSchema (e.g., using z.object with id: z.string(), title: z.string(),
spec: appropriate schema or z.any() if Spec can't be expressed) and use it where
configs are parsed/loaded to parse/validate inputs; update any factory/loader
code that consumes ChartConfig to call ChartConfigSchema.parse() (or safeParse)
so runtime validation is enforced.

In `@packages/mosaic/src/chart-builders/builders.ts`:
- Around line 20-93: Both builders (countPlotBuilder and histogramBuilder)
duplicate the same createSpec logic; extract a shared factory (e.g.,
createHistogramBuilder) that accepts parameters for id, icon, description,
maxbins, yLabel, and leftMargin and returns a MosaicChartBuilder; inside the
factory implement the common plot array (reusing BG_COLOR, FG_COLOR, inset,
params, and select names) and use it to construct countPlotBuilder and
histogramBuilder by passing (25, null, 0) and (40, 'Count', 40) respectively,
keeping fields and createSpec signatures identical.

In `@packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx`:
- Around line 60-64: The hardcoded 200ms delay in handleClose
(setTimeout(handleReset, 200)) is fragile; replace it by either listening for
the dialog's animation end event or by extracting the delay into a clearly named
constant and referencing that constant, and then call handleReset only after the
animation completes; update the handler around onOpenChange and handleReset in
ChartBuilderDialog.tsx (function handleClose) to trigger reset via an
onAnimationEnd/onTransitionEnd callback on the dialog component or use a named
constant like DIALOG_CLOSE_ANIMATION_MS instead of magic number 200 so the
timing remains correct and maintainable.
- Around line 75-80: The current canCreate logic treats undefined required as
required implicitly; update the check to make that explicit and/or document the
default: replace the filter predicate with (f.required ?? true) so the code
reads selectedBuilder.fields.filter(f => (f.required ?? true)).every(f =>
fieldValues[f.key]); also add a short comment or JSDoc on the
MosaicChartBuilderField interface indicating that required defaults to true to
make the behavior explicit (referencing selectedBuilder, canCreate, and
MosaicChartBuilderField).
- Around line 156-167: Add a Cancel button in the DialogFooter when in step 2
(i.e., when selectedBuilder is truthy) that closes the dialog without creating
anything; place it alongside the existing Back and Create buttons and wire it to
the dialog close handler (use the component's existing onClose/close handler or
add one if missing), ensuring it does not affect canCreate logic and that
handleReset remains attached to the Back button and handleCreate to Create.

In `@packages/mosaic/src/chart-builders/FieldSelectorInput.tsx`:
- Around line 36-42: The current filtering for filteredColumns uses partial
matching via col.type.toUpperCase().includes(t.toUpperCase()), which allows e.g.
"INT" to match "INTEGER"; change the comparison in the FieldSelectorInput
filtering logic to perform an exact case-insensitive match (e.g., compare
col.type.toUpperCase() === t.toUpperCase()) or switch to a stricter pattern
(anchored regex) so field.types items only match exact column types; update the
filter expression around field.types, columns, and col.type accordingly.

In `@packages/mosaic/src/chart-builders/types.ts`:
- Around line 7-16: Add a Zod schema for the MosaicChartBuilderField interface
so configurations can be runtime-validated: create a
MosaicChartBuilderFieldSchema using z.object(...) that requires key and label as
strings and marks required as optional boolean and types as optional array of
strings, then export type MosaicChartBuilderField = z.infer<typeof
MosaicChartBuilderFieldSchema>; update any code that imports the interface to
use the inferred type or accept the schema for validation where builder configs
are parsed.

In `@packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx`:
- Around line 33-41: When enableSchemaValidation toggles off the existing schema
in state should be cleared to avoid keeping unused data; update the useEffect
that depends on enableSchemaValidation (the one calling loadMosaicSchema) so
that when enableSchemaValidation is false you call setSchema(null or undefined)
(and keep the existing branch that loads and sets the schema when true), and
ensure loadMosaicSchema and setSchema usage remain consistent with the chosen
empty value.

In `@packages/mosaic/src/editor/mosaicSchema.ts`:
- Around line 26-48: The fetch of MOSAIC_SCHEMA_URL inside the async IIFE that
assigns loadingPromise should use an AbortSignal timeout to avoid hanging:
create an AbortController (or use AbortSignal.timeout if available), pass its
signal to fetch(MOSAIC_SCHEMA_URL, { signal }), and set a timeout to call
controller.abort() (or rely on AbortSignal.timeout) before awaiting the
response; ensure you catch the abort error in the existing catch block and still
set cachedSchema fallback and clear loadingPromise in the finally block—update
references in this function to use the controller/signal and avoid changing the
existing cachedSchema/error handling logic.

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts`:
- Around line 143-146: The current useEffect updates onSpecChangeRef.current on
every render; replace that pattern by removing the useEffect and directly
assigning the prop into the ref (keep the existing const onSpecChangeRef =
useRef(onSpecChange)) by setting onSpecChangeRef.current = onSpecChange in the
component body so the ref stays current without an effect—refer to
onSpecChangeRef, useRef and onSpecChange to locate the change.
- Around line 152-187: The applyChanges function contains a nested
setDebouncedParsed -> setEditorState with an inline IIFE dirty-check; extract
that dirty-check into a small pure utility (e.g., isSpecDirty(editedSpecString,
appliedSpecString)) and replace the inline try/catch IIFE with a call to that
utility inside setEditorState, keeping the same behavior for calling
onSpecChangeRef.current and updating appliedSpecString/prevInitialSpecString;
ensure the utility handles JSON.parse errors the same way and update
imports/refs so applyChanges, setDebouncedParsed, setEditorState and
onSpecChangeRef use the new function.

In `@packages/mosaic/src/VgPlotChart.tsx`:
- Around line 36-53: The async effect in VgPlotChart should avoid doing work
after unmount: inside the useEffect that calls parseSpec and astToDOM, add a
cleanup flag (e.g., let cancelled = false) and, if available, pass an
AbortSignal to parseSpec/astToDOM; after each await check the flag (and signal)
before creating the element or calling containerRef.current.replaceChildren.
Ensure the cleanup function sets cancelled = true (and aborts the signal if
used) so no DOM creation or expensive work proceeds after unmount; reference the
useEffect block, parseSpec, astToDOM, containerRef, and replaceChildren when
applying this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 720841b0-05a9-48c8-97a4-4290a3d1670e

📥 Commits

Reviewing files that changed from the base of the PR and between c0de1db and 9f0ad5b.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx
  • examples/deckgl-mosaic/src/components/filters/filterPlots.ts
  • packages/mosaic/package.json
  • packages/mosaic/src/VgPlotChart.tsx
  • packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx
  • packages/mosaic/src/chart-builders/FieldSelectorInput.tsx
  • packages/mosaic/src/chart-builders/builders.ts
  • packages/mosaic/src/chart-builders/types.ts
  • packages/mosaic/src/editor/MosaicChartContainer.tsx
  • packages/mosaic/src/editor/MosaicChartDisplay.tsx
  • packages/mosaic/src/editor/MosaicChartEditorActions.tsx
  • packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx
  • packages/mosaic/src/editor/MosaicEditorContext.tsx
  • packages/mosaic/src/editor/MosaicSpecEditorPanel.tsx
  • packages/mosaic/src/editor/mosaicSchema.ts
  • packages/mosaic/src/editor/types.ts
  • packages/mosaic/src/editor/useMosaicChartEditor.ts
  • packages/mosaic/src/index.ts

Comment thread examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx
Comment thread examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx Outdated
Comment thread packages/mosaic/package.json Outdated
Comment thread packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx

@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

🧹 Nitpick comments (1)
examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx (1)

33-34: Use memoized selector references for all useRoomStore calls in this file.

This file has four inline selector lambdas that should be replaced with stable memoized selectors. Define them at module level (outside components) for reuse.

♻️ Proposed refactor
+const selectMosaicConnection = (state: ReturnType<typeof useRoomStore.getState>) =>
+  state.mosaic.connection;
+const selectIsTableReady = (state: ReturnType<typeof useRoomStore.getState>) =>
+  state.db.tables.find((t) => t.tableName === 'earthquakes');
+const selectBrush = (state: ReturnType<typeof useRoomStore.getState>) =>
+  state.mosaic.getSelection('brush');
+const selectTables = (state: ReturnType<typeof useRoomStore.getState>) =>
+  state.db.tables;
+
 export default function FiltersPanel({className}: {className?: string}) {
-  const mosaicConn = useRoomStore((state) => state.mosaic.connection);
-  const isTableReady = useRoomStore((state) =>
-    state.db.tables.find((t) => t.tableName === 'earthquakes'),
-  );
+  const mosaicConn = useRoomStore(selectMosaicConnection);
+  const isTableReady = useRoomStore(selectIsTableReady);
   // ...
 }

 const FiltersPanelContent = ({className}: {className?: string}) => {
-  const brush = useRoomStore((state) => state.mosaic.getSelection('brush'));
-  const tables = useRoomStore((state) => state.db.tables);
+  const brush = useRoomStore(selectBrush);
+  const tables = useRoomStore(selectTables);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx` around lines
33 - 34, The inline selector lambdas passed to useRoomStore (e.g., the ones that
produce brush via state.mosaic.getSelection('brush') and tables via
state.db.tables) should be replaced by stable, module-level memoized selector
functions; define named selectors (for example selectBrush, selectTables and the
other two selectors used in this file) outside the FiltersPanel component and
reuse them in the four useRoomStore calls so the selector references are stable
across renders and avoid unnecessary re-subscriptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx`:
- Around line 80-83: The current handleCreateChart implementation uses
Date.now() for chart IDs which can collide; replace this with a collision-safe
ID generator (e.g., nanoid/uuid) or a persistent incrementing counter stored in
a ref to guarantee unique IDs. Update handleCreateChart (and any
callers/consumers of chart.id) to use the new ID value and ensure setCharts
continues to prepend the new chart ({id, title, spec}) without relying on
Date.now().
- Around line 124-144: The icon-only action Buttons for editing and removing
charts lack explicit accessible names; add aria-label attributes to the Button
that calls toggleEditor(chart.id) (the Edit spec button with the Code icon) and
to the Button that calls handleRemoveChart(chart.id) (the Remove chart button
with the X icon), using the same text as the existing title props (e.g.,
aria-label="Hide editor" / "Edit spec" and aria-label="Remove chart") so
assistive tech receives a reliable accessible name.

---

Nitpick comments:
In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx`:
- Around line 33-34: The inline selector lambdas passed to useRoomStore (e.g.,
the ones that produce brush via state.mosaic.getSelection('brush') and tables
via state.db.tables) should be replaced by stable, module-level memoized
selector functions; define named selectors (for example selectBrush,
selectTables and the other two selectors used in this file) outside the
FiltersPanel component and reuse them in the four useRoomStore calls so the
selector references are stable across renders and avoid unnecessary
re-subscriptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5f7aba0c-756c-4d74-9742-1dc2f18e550f

📥 Commits

Reviewing files that changed from the base of the PR and between 9f0ad5b and 0eaba0a.

📒 Files selected for processing (2)
  • examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx
  • packages/mosaic/src/chart-builders/builders.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/mosaic/src/chart-builders/builders.ts

Comment on lines +80 to +83
const handleCreateChart = useCallback((spec: Spec, title: string) => {
const id = `chart-${Date.now()}`;
setCharts((prev) => [{id, title, spec}, ...prev]);
}, []);

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

Avoid Date.now() for chart IDs to prevent key collisions.

Fast consecutive chart creation can generate duplicate IDs in the same millisecond, which can break React key stability and target the wrong chart for edit/remove actions.

🐛 Proposed fix
   const handleCreateChart = useCallback((spec: Spec, title: string) => {
-    const id = `chart-${Date.now()}`;
+    const id = `chart-${crypto.randomUUID()}`;
     setCharts((prev) => [{id, title, spec}, ...prev]);
   }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx` around lines
80 - 83, The current handleCreateChart implementation uses Date.now() for chart
IDs which can collide; replace this with a collision-safe ID generator (e.g.,
nanoid/uuid) or a persistent incrementing counter stored in a ref to guarantee
unique IDs. Update handleCreateChart (and any callers/consumers of chart.id) to
use the new ID value and ensure setCharts continues to prepend the new chart
({id, title, spec}) without relying on Date.now().

Comment on lines +124 to +144
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => toggleEditor(chart.id)}
title={isEditing ? 'Hide editor' : 'Edit spec'}
>
<Code className="h-3.5 w-3.5" />
</Button>
{!defaultChartConfigs.some(
(d) => d.id === chart.id,
) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleRemoveChart(chart.id)}
title="Remove chart"
>
<X className="h-3.5 w-3.5" />
</Button>

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

Add explicit accessible names to icon-only action buttons.

title alone is not a reliable accessible name for assistive tech; add aria-label on icon-only buttons.

♿ Proposed fix
                           <Button
                             variant="ghost"
                             size="icon"
                             className="h-6 w-6"
                             onClick={() => toggleEditor(chart.id)}
+                            aria-label={isEditing ? 'Hide editor' : 'Edit spec'}
                             title={isEditing ? 'Hide editor' : 'Edit spec'}
                           >
@@
                             <Button
                               variant="ghost"
                               size="icon"
                               className="h-6 w-6"
                               onClick={() => handleRemoveChart(chart.id)}
+                              aria-label="Remove chart"
                               title="Remove chart"
                             >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx` around lines
124 - 144, The icon-only action Buttons for editing and removing charts lack
explicit accessible names; add aria-label attributes to the Button that calls
toggleEditor(chart.id) (the Edit spec button with the Code icon) and to the
Button that calls handleRemoveChart(chart.id) (the Remove chart button with the
X icon), using the same text as the existing title props (e.g., aria-label="Hide
editor" / "Edit spec" and aria-label="Remove chart") so assistive tech receives
a reliable accessible name.

ilyabo added 2 commits March 17, 2026 14:18
Signed-off-by: Ilya Boyandin <[email protected]>
Signed-off-by: Ilya Boyandin <[email protected]>

@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: 4

🤖 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/mosaic/src/chart-builders/ChartBuilderDialog.tsx`:
- Around line 62-65: The dialog reset is only scheduled in handleClose, so when
the parent toggles the dialog (or ESC/backdrop/X triggers onOpenChange directly)
the wizard state can persist; fix by ensuring handleReset runs whenever the
dialog closes by adding a watcher for the open prop (or wrapping all direct
onOpenChange calls) that, when open becomes false, schedules
setTimeout(handleReset, DIALOG_CLOSE_ANIMATION_MS); keep handleClose as-is (it
should call onOpenChange(false) then schedule handleReset), and ensure any
places that currently call onOpenChange(false) directly use the same wrapper or
rely on the new useEffect so handleReset always runs.

In `@packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx`:
- Around line 33-41: The async load in the useEffect with enableSchemaValidation
can call setSchema after unmount/toggle, causing React warnings; update the
effect that calls loadMosaicSchema() to use a cancellation flag (e.g., let
cancelled = false) and set cancelled = true in the cleanup, then only call
setSchema(loadedSchema) when not cancelled; reference the useEffect block that
checks enableSchemaValidation, the loadMosaicSchema() promise, and the setSchema
state updater to implement this guard.

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts`:
- Around line 64-73: The sync block that updates editorState on initialSpec
change is not updating originalSpecString, so resetToOriginal can revert to a
stale mount-time spec; update the setEditorState call (the branch that checks
currentInitialSpec.normalized !== editorState.prevInitialSpecString) to also set
originalSpecString: currentInitialSpec.normalized, and ensure the
resetToOriginal handler (resetToOriginal function / code that uses
originalSpecString around lines 216–219) references the updated
prevInitialSpecString or the currentInitialSpec.normalized instead of a stale
original value so resets always reflect the latest incoming prop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8c00c03e-d061-4424-bdb5-0a464f1c7b1a

📥 Commits

Reviewing files that changed from the base of the PR and between 0eaba0a and 3fad8c7.

📒 Files selected for processing (7)
  • examples/deckgl-mosaic/src/components/filters/filterPlots.ts
  • packages/mosaic/package.json
  • packages/mosaic/src/VgPlotChart.tsx
  • packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx
  • packages/mosaic/src/chart-builders/FieldSelectorInput.tsx
  • packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx
  • packages/mosaic/src/editor/useMosaicChartEditor.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/mosaic/src/chart-builders/FieldSelectorInput.tsx
  • packages/mosaic/package.json

Comment on lines +62 to +65
const handleClose = useCallback(() => {
onOpenChange(false);
setTimeout(handleReset, DIALOG_CLOSE_ANIMATION_MS);
}, [onOpenChange, handleReset]);

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

Reset wizard state on all dialog close paths (not only Create).

handleReset is only scheduled inside handleClose, but Line 102 wires onOpenChange directly to the parent callback. If the dialog closes via ESC/backdrop/X/parent state, internal wizard state can persist and reopen on the previous step.

💡 Suggested fix
-import React, {useCallback, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
@@
-  const handleClose = useCallback(() => {
-    onOpenChange(false);
-    setTimeout(handleReset, DIALOG_CLOSE_ANIMATION_MS);
-  }, [onOpenChange, handleReset]);
+  useEffect(() => {
+    if (!open) {
+      const timeoutId = setTimeout(handleReset, DIALOG_CLOSE_ANIMATION_MS);
+      return () => clearTimeout(timeoutId);
+    }
+  }, [open, handleReset]);
+
+  const handleClose = useCallback(() => {
+    onOpenChange(false);
+  }, [onOpenChange]);
@@
-    <Dialog open={open} onOpenChange={onOpenChange}>
+    <Dialog open={open} onOpenChange={onOpenChange}>

Also applies to: 102-102

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

In `@packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx` around lines 62 -
65, The dialog reset is only scheduled in handleClose, so when the parent
toggles the dialog (or ESC/backdrop/X triggers onOpenChange directly) the wizard
state can persist; fix by ensuring handleReset runs whenever the dialog closes
by adding a watcher for the open prop (or wrapping all direct onOpenChange
calls) that, when open becomes false, schedules setTimeout(handleReset,
DIALOG_CLOSE_ANIMATION_MS); keep handleClose as-is (it should call
onOpenChange(false) then schedule handleReset), and ensure any places that
currently call onOpenChange(false) directly use the same wrapper or rely on the
new useEffect so handleReset always runs.

Comment thread packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx
Comment on lines +64 to +73
if (currentInitialSpec.normalized !== editorState.prevInitialSpecString) {
setEditorState((prev) => ({
...prev,
prevInitialSpecString: currentInitialSpec.normalized,
appliedSpecString: currentInitialSpec.normalized,
editedSpecString: currentInitialSpec.formatted,
lastValidSpec: currentInitialSpec.parseOk
? (currentInitialSpec.parsed as Spec)
: prev.lastValidSpec,
}));

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

resetToOriginal can revert to an outdated spec after initialSpec prop changes.

On prop sync (Line 64–Line 73), originalSpecString is not updated. Later, Line 216 resets to that stale value, which can restore the mount-time spec instead of the current externally provided one.

Suggested fix
   if (currentInitialSpec.normalized !== editorState.prevInitialSpecString) {
     setEditorState((prev) => ({
       ...prev,
+      originalSpecString: currentInitialSpec.formatted,
       prevInitialSpecString: currentInitialSpec.normalized,
       appliedSpecString: currentInitialSpec.normalized,
       editedSpecString: currentInitialSpec.formatted,
       lastValidSpec: currentInitialSpec.parseOk
         ? (currentInitialSpec.parsed as Spec)
         : prev.lastValidSpec,
     }));
   }

Also applies to: 216-219

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

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts` around lines 64 - 73, The
sync block that updates editorState on initialSpec change is not updating
originalSpecString, so resetToOriginal can revert to a stale mount-time spec;
update the setEditorState call (the branch that checks
currentInitialSpec.normalized !== editorState.prevInitialSpecString) to also set
originalSpecString: currentInitialSpec.normalized, and ensure the
resetToOriginal handler (resetToOriginal function / code that uses
originalSpecString around lines 216–219) references the updated
prevInitialSpecString or the currentInitialSpec.normalized instead of a stale
original value so resets always reflect the latest incoming prop.

Comment on lines +152 to +157
const applyChanges = useCallback(() => {
setDebouncedParsed((currentParsed) => {
if (!currentParsed.parsedSpec) return currentParsed;

const newAppliedSpecString = JSON.stringify(currentParsed.parsedSpec);

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

Apply can commit a stale spec if clicked before debounce settles.

Line 153 reads currentParsed from debounced state. If the user edits JSON and clicks Apply within 300ms, onSpecChange can receive the previous spec instead of the latest editor contents.

Suggested fix
-  const applyChanges = useCallback(() => {
-    setDebouncedParsed((currentParsed) => {
-      if (!currentParsed.parsedSpec) return currentParsed;
-
-      const newAppliedSpecString = JSON.stringify(currentParsed.parsedSpec);
+  const applyChanges = useCallback(() => {
+    let parsed: Spec;
+    try {
+      parsed = JSON.parse(editedSpecString) as Spec;
+    } catch {
+      return;
+    }
+    const newAppliedSpecString = JSON.stringify(parsed);

       setEditorState((prev) => {
         const dirty = (() => {
           try {
             return (
               JSON.stringify(JSON.parse(prev.editedSpecString)) !==
               prev.appliedSpecString
             );
           } catch {
             return true;
           }
         })();

         if (dirty) {
-          onSpecChangeRef.current?.(currentParsed.parsedSpec!);
+          onSpecChangeRef.current?.(parsed);
         }

         return {
           ...prev,
           appliedSpecString: dirty
             ? newAppliedSpecString
             : prev.appliedSpecString,
           prevInitialSpecString: dirty
             ? newAppliedSpecString
             : prev.prevInitialSpecString,
         };
       });
-
-      return currentParsed;
-    });
-  }, []);
+  }, [editedSpecString]);

Also applies to: 170-172

ilyabo added 3 commits March 17, 2026 17:12
Signed-off-by: Ilya Boyandin <[email protected]>
Signed-off-by: Ilya Boyandin <[email protected]>
Signed-off-by: Ilya Boyandin <[email protected]>

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

♻️ Duplicate comments (1)
packages/mosaic/src/editor/useMosaicChartEditor.ts (1)

153-188: ⚠️ Potential issue | 🟠 Major

applyChanges can commit stale spec if invoked before debounce settles.

When the user edits JSON and clicks Apply within 300ms (before the debounce timer fires), applyChanges reads from debouncedParsed which still holds the previous parsed spec. This can cause the applied spec to differ from what the user sees in the editor.

Consider parsing directly from editedSpecString in applyChanges to ensure the latest editor content is applied.

🐛 Proposed fix
-  const applyChanges = useCallback(() => {
-    setDebouncedParsed((currentParsed) => {
-      if (!currentParsed.parsedSpec) return currentParsed;
-
-      const newAppliedSpecString = JSON.stringify(currentParsed.parsedSpec);
+  const applyChanges = useCallback(() => {
+    let parsed: Spec;
+    try {
+      parsed = JSON.parse(editorState.editedSpecString) as Spec;
+    } catch {
+      return; // Can't apply invalid JSON
+    }
+    const newAppliedSpecString = JSON.stringify(parsed);

       setEditorState((prev) => {
         const dirty = (() => {
           try {
             return (
               JSON.stringify(JSON.parse(prev.editedSpecString)) !==
               prev.appliedSpecString
             );
           } catch {
             return true;
           }
         })();

         if (dirty) {
-          onSpecChangeRef.current?.(currentParsed.parsedSpec!);
+          onSpecChangeRef.current?.(parsed);
         }

         return {
           ...prev,
           appliedSpecString: dirty
             ? newAppliedSpecString
             : prev.appliedSpecString,
           prevInitialSpecString: dirty
             ? newAppliedSpecString
             : prev.prevInitialSpecString,
         };
       });
-
-      return currentParsed;
-    });
-  }, []);
+  }, [editorState.editedSpecString]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts` around lines 153 - 188,
The applyChanges function currently reads from debouncedParsed and can apply a
stale spec; instead, synchronously parse the latest editedSpecString inside
applyChanges (use editorState.editedSpecString or a ref to it), handle JSON
parse errors (abort/apply nothing on parse failure), then compute
newAppliedSpecString = JSON.stringify(parsed) and call
onSpecChangeRef.current(parsed) if dirty, and update setEditorState to set
appliedSpecString and prevInitialSpecString to newAppliedSpecString; keep the
existing dirty-detection logic but compare against prev.appliedSpecString using
the freshly parsed string so the exact latest editor content is applied even if
debounce hasn't settled.
🧹 Nitpick comments (1)
packages/mosaic/src/editor/useMosaicChartEditor.ts (1)

127-129: State update during render may cause extra re-renders.

Calling setEditorState during render (outside of an effect) can lead to cascading re-renders. While React handles this gracefully for derived state patterns, it's cleaner to use useEffect for this synchronization.

♻️ Consider moving to useEffect
+  useEffect(() => {
+    if (parsedSpec && parsedSpec !== editorState.lastValidSpec) {
+      setEditorState((prev) => ({...prev, lastValidSpec: parsedSpec}));
+    }
+  }, [parsedSpec, editorState.lastValidSpec]);
-
-  if (parsedSpec && parsedSpec !== editorState.lastValidSpec) {
-    setEditorState((prev) => ({...prev, lastValidSpec: parsedSpec}));
-  }
   const lastValidSpec = editorState.lastValidSpec;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mosaic/src/editor/useMosaicChartEditor.ts` around lines 127 - 129,
The code updates editorState.lastValidSpec during render when parsedSpec
differs, which can cause extra re-renders; move this synchronization into a
useEffect: create an effect that runs when parsedSpec (and optionally
editorState.lastValidSpec) changes, compare parsedSpec to
editorState.lastValidSpec inside the effect and call setEditorState(prev =>
({...prev, lastValidSpec: parsedSpec})) only when they differ; ensure the effect
dependency list includes parsedSpec (and editorState.lastValidSpec or use a
functional updater to avoid stale reads) so the update happens after render
rather than during it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/mosaic/src/editor/useMosaicChartEditor.ts`:
- Around line 153-188: The applyChanges function currently reads from
debouncedParsed and can apply a stale spec; instead, synchronously parse the
latest editedSpecString inside applyChanges (use editorState.editedSpecString or
a ref to it), handle JSON parse errors (abort/apply nothing on parse failure),
then compute newAppliedSpecString = JSON.stringify(parsed) and call
onSpecChangeRef.current(parsed) if dirty, and update setEditorState to set
appliedSpecString and prevInitialSpecString to newAppliedSpecString; keep the
existing dirty-detection logic but compare against prev.appliedSpecString using
the freshly parsed string so the exact latest editor content is applied even if
debounce hasn't settled.

---

Nitpick comments:
In `@packages/mosaic/src/editor/useMosaicChartEditor.ts`:
- Around line 127-129: The code updates editorState.lastValidSpec during render
when parsedSpec differs, which can cause extra re-renders; move this
synchronization into a useEffect: create an effect that runs when parsedSpec
(and optionally editorState.lastValidSpec) changes, compare parsedSpec to
editorState.lastValidSpec inside the effect and call setEditorState(prev =>
({...prev, lastValidSpec: parsedSpec})) only when they differ; ensure the effect
dependency list includes parsedSpec (and editorState.lastValidSpec or use a
functional updater to avoid stale reads) so the update happens after render
rather than during it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cbd22626-76d8-43db-92dd-2a018f0a5960

📥 Commits

Reviewing files that changed from the base of the PR and between 3fad8c7 and 21d3036.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • examples/deckgl-mosaic/src/components/filters/FiltersPanel.tsx
  • packages/mosaic/src/MosaicChart.ts
  • packages/mosaic/src/MosaicChartBuilder.ts
  • packages/mosaic/src/chart-builders/ChartBuilderContent.tsx
  • packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx
  • packages/mosaic/src/chart-builders/FieldSelectorInput.tsx
  • packages/mosaic/src/chart-builders/builders.ts
  • packages/mosaic/src/chart-builders/types.ts
  • packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx
  • packages/mosaic/src/editor/useMosaicChartEditor.ts
  • packages/mosaic/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/mosaic/src/chart-builders/ChartBuilderDialog.tsx
  • packages/mosaic/src/chart-builders/builders.ts
  • packages/mosaic/src/editor/MosaicCodeMirrorEditor.tsx

Signed-off-by: Ilya Boyandin <[email protected]>
@ilyabo
ilyabo merged commit 9d4a874 into main Mar 18, 2026
3 checks passed
@ilyabo
ilyabo deleted the mosaic-chart-builders branch March 18, 2026 19:36
dmitriy-kostianetskiy pushed a commit that referenced this pull request Mar 20, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jun 2, 2026
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.

1 participant