Skip to content

Commit d29cc8a

Browse files
fix(store): hydrate initial chart metadata (#519)
Hydrate the untouched initial visualization when asynchronous metadata first arrives while preserving existing user charts and undo history. Fixes #189
1 parent 8878dd3 commit d29cc8a

3 files changed

Lines changed: 219 additions & 24 deletions

File tree

‎packages/graphic-walker/src/store/index.tsx‎

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useMemo, useEffect, createContext, useRef } from 'react';
1+
import React, { useContext, useMemo, useEffect, createContext } from 'react';
22
import { VizSpecStore } from './visualSpecStore';
33
import { IComputationFunction, IDefaultConfig, IMutField, IRow } from '../interfaces';
44

@@ -51,33 +51,25 @@ export const VizStoreWrapper = (props: VizStoreWrapperProps) => {
5151
return getVizStore(storeKey, props.meta, { onMetaChange: props.onMetaChange, defaultConfig });
5252
// IMPORTANT the store is only associated with the storeKey
5353
}, [storeKey]);
54-
const lastMeta = useRef(props.meta);
54+
55+
// Keep this effect before metadata sync: first hydration reads the current
56+
// default config when rebuilding the untouched loading placeholder.
5557
useEffect(() => {
56-
if (lastMeta.current !== props.meta) {
57-
store.setMeta(props.meta);
58-
lastMeta.current = props.meta;
59-
}
60-
}, [props.meta, store]);
61-
const lastOnMetaChange = useRef(props.onMetaChange);
58+
const defaultConfig = props.defaultRenderer
59+
? { ...props.defaultConfig, layout: { renderer: props.defaultRenderer, ...(props.defaultConfig?.layout ?? {}) } }
60+
: props.defaultConfig;
61+
store.setDefaultConfig(defaultConfig);
62+
}, [props.defaultConfig, props.defaultRenderer, store]);
63+
6264
useEffect(() => {
63-
if (lastOnMetaChange.current !== props.onMetaChange) {
64-
store.setOnMetaChange(props.onMetaChange);
65-
lastOnMetaChange.current = props.onMetaChange;
66-
}
65+
// Always sync once on mount so a keepAlive store cannot retain stale
66+
// loading metadata across an unmount/remount boundary.
67+
store.setMeta(props.meta);
6768
}, [props.meta, store]);
6869

69-
const lastDefaultConfig = useRef(props.defaultConfig);
70-
const lastDefaultRenderer = useRef(props.defaultRenderer);
7170
useEffect(() => {
72-
if (lastDefaultConfig.current !== props.defaultConfig || lastDefaultRenderer.current !== props.defaultRenderer) {
73-
const defaultConfig = props.defaultRenderer
74-
? { ...props.defaultConfig, layout: { renderer: props.defaultRenderer, ...(props.defaultConfig?.layout ?? {}) } }
75-
: props.defaultConfig;
76-
store.setDefaultConfig(defaultConfig);
77-
lastDefaultConfig.current = props.defaultConfig;
78-
lastDefaultRenderer.current = props.defaultRenderer;
79-
}
80-
}, [props.defaultConfig, props.defaultRenderer, store]);
71+
store.setOnMetaChange(props.onMetaChange);
72+
}, [props.onMetaChange, store]);
8173

8274
useEffect(() => {
8375
if (props.storeRef) {
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { IMutField } from '../interfaces';
2+
import { newChart } from '../models/visSpecHistory';
3+
import { VizSpecStore } from './visualSpecStore';
4+
5+
const META: IMutField[] = [
6+
{ fid: 'category', name: 'Category', semanticType: 'nominal', analyticType: 'dimension' },
7+
{ fid: 'sales', name: 'Sales', semanticType: 'quantitative', analyticType: 'measure' },
8+
];
9+
10+
const dispatchEvent = jest.fn();
11+
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
12+
const originalCustomEvent = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent');
13+
14+
const metaFieldIds = (store: VizSpecStore) => {
15+
const expectedIds = new Set(META.map((field) => field.fid));
16+
return [...store.currentEncodings.dimensions, ...store.currentEncodings.measures]
17+
.map((field) => field.fid)
18+
.filter((fid) => expectedIds.has(fid));
19+
};
20+
21+
describe('VizSpecStore metadata updates', () => {
22+
beforeAll(() => {
23+
Object.defineProperty(globalThis, 'document', {
24+
configurable: true,
25+
value: { dispatchEvent },
26+
});
27+
Object.defineProperty(globalThis, 'CustomEvent', {
28+
configurable: true,
29+
value: class {
30+
constructor(
31+
public type: string,
32+
public init?: CustomEventInit,
33+
) {}
34+
},
35+
});
36+
});
37+
38+
beforeEach(() => {
39+
dispatchEvent.mockClear();
40+
});
41+
42+
afterAll(() => {
43+
if (originalDocument) {
44+
Object.defineProperty(globalThis, 'document', originalDocument);
45+
} else {
46+
Reflect.deleteProperty(globalThis, 'document');
47+
}
48+
if (originalCustomEvent) {
49+
Object.defineProperty(globalThis, 'CustomEvent', originalCustomEvent);
50+
} else {
51+
Reflect.deleteProperty(globalThis, 'CustomEvent');
52+
}
53+
});
54+
55+
test('hydrates the initial chart when asynchronous metadata first arrives', () => {
56+
const store = new VizSpecStore([]);
57+
const initialVisId = store.currentVis.visId;
58+
59+
// App.tsx creates a fresh [] while async fields are still unavailable.
60+
store.setMeta([]);
61+
store.setMeta([]);
62+
store.setMeta(META);
63+
expect(dispatchEvent).toHaveBeenCalledTimes(1);
64+
expect(dispatchEvent.mock.calls[0][0]).toMatchObject({ type: 'edit-graphic-walker' });
65+
expect(store.canUndo).toBe(false);
66+
const chartOneFieldsAfterHydration = metaFieldIds(store);
67+
68+
store.addVisualization();
69+
const chartTwoFields = metaFieldIds(store);
70+
71+
store.selectVisualization(0);
72+
73+
expect({
74+
chartOneFieldsAfterHydration,
75+
chartTwoFields,
76+
chartOneFieldsAfterSwitchingBack: metaFieldIds(store),
77+
}).toEqual({
78+
chartOneFieldsAfterHydration: ['category', 'sales'],
79+
chartTwoFields: ['category', 'sales'],
80+
chartOneFieldsAfterSwitchingBack: ['category', 'sales'],
81+
});
82+
expect(store.currentVis.visId).toBe(initialVisId);
83+
});
84+
85+
test('preserves an existing user chart when metadata props update', () => {
86+
const store = new VizSpecStore(META);
87+
store.moveField('dimensions', 0, 'columns', 0);
88+
const editedChart = store.currentVis;
89+
const updatedMeta: IMutField[] = [
90+
...META,
91+
{ fid: 'profit', name: 'Profit', semanticType: 'quantitative', analyticType: 'measure' },
92+
];
93+
94+
store.setMeta(updatedMeta);
95+
96+
expect(store.meta).toEqual(updatedMeta);
97+
expect(store.currentVis).toBe(editedChart);
98+
expect(store.currentEncodings.columns.map((field) => field.fid)).toEqual(['category']);
99+
});
100+
101+
test('preserves a chart edited before asynchronous metadata arrives', () => {
102+
const store = new VizSpecStore([]);
103+
store.setVisualConfig('geoms', ['line']);
104+
const editedChart = store.currentVis;
105+
106+
store.setMeta(META);
107+
108+
expect(store.currentVis).toBe(editedChart);
109+
expect(store.currentVis.config.geoms).toEqual(['line']);
110+
expect(metaFieldIds(store)).toEqual([]);
111+
});
112+
113+
test('preserves a supplied chart when asynchronous metadata arrives', () => {
114+
const store = new VizSpecStore([]);
115+
store.replaceNow(newChart(META, 'Supplied chart', 'supplied-vis-id'));
116+
const suppliedChart = store.currentVis;
117+
118+
store.setMeta(META);
119+
120+
expect(store.currentVis).toBe(suppliedChart);
121+
expect(store.currentVis.visId).toBe('supplied-vis-id');
122+
expect(metaFieldIds(store)).toEqual(['category', 'sales']);
123+
});
124+
125+
test('preserves multiple charts created before asynchronous metadata arrives', () => {
126+
const store = new VizSpecStore([]);
127+
const firstChart = store.visList[0];
128+
store.addVisualization();
129+
const secondChart = store.visList[1];
130+
131+
store.setMeta(META);
132+
133+
expect(store.visList[0]).toBe(firstChart);
134+
expect(store.visList[1]).toBe(secondChart);
135+
});
136+
137+
test('hydrates a reset placeholder when metadata arrives', () => {
138+
const store = new VizSpecStore([]);
139+
store.resetVisualization('Reset while loading');
140+
const resetVisId = store.currentVis.visId;
141+
142+
store.setMeta(META);
143+
144+
expect(store.currentVis.name).toBe('Reset while loading');
145+
expect(store.currentVis.visId).toBe(resetVisId);
146+
expect(metaFieldIds(store)).toEqual(['category', 'sales']);
147+
});
148+
149+
test('hydrates the remaining pristine placeholder after the first chart is removed', () => {
150+
const store = new VizSpecStore([]);
151+
store.addVisualization();
152+
const remainingVisId = store.visList[1].now.visId;
153+
store.removeVisualization(0);
154+
155+
store.setMeta(META);
156+
157+
expect(store.currentVis.visId).toBe(remainingVisId);
158+
expect(metaFieldIds(store)).toEqual(['category', 'sales']);
159+
});
160+
161+
test('keeps an explicitly empty store chartless when metadata arrives', () => {
162+
const store = new VizSpecStore([], { empty: true });
163+
164+
store.setMeta(META);
165+
166+
expect(store.visLength).toBe(0);
167+
expect(store.meta).toEqual(META);
168+
});
169+
170+
test('uses the latest default config when hydrating the initial chart', () => {
171+
const store = new VizSpecStore([]);
172+
store.setDefaultConfig({ layout: { renderer: 'observable-plot' } });
173+
174+
store.setMeta(META);
175+
176+
expect(store.currentVis.layout.renderer).toBe('observable-plot');
177+
});
178+
});

‎packages/graphic-walker/src/store/visualSpecStore.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
redo,
1515
undo,
1616
} from '../models/visSpecHistory';
17+
import { create } from '../models/withHistory';
1718
import { emptyEncodings, forwardVisualConfigs, visSpecDecoder } from '../utils/save';
1819
import { feature } from 'topojson-client';
1920
import type { FeatureCollection } from 'geojson';
@@ -62,6 +63,7 @@ const disposerRegister = (typeof FinalizationRegistry === 'undefined' ? null : n
6263
export class VizSpecStore {
6364
instanceID: string = uniqueId();
6465
visList: VisSpecWithHistory[];
66+
private pristineInitialCharts: Set<VisSpecWithHistory>;
6567
visIndex: number = 0;
6668
createdVis: number = 0;
6769
editingFilterIdx: number | null = null;
@@ -107,11 +109,15 @@ export class VizSpecStore {
107109
) {
108110
this.meta = meta;
109111
this.visList = options?.empty ? [] : [fromFields(meta, 'Chart 1', options?.defaultConfig)];
112+
this.pristineInitialCharts = meta.length === 0 ? new Set(this.visList) : new Set();
110113
this.createdVis = this.visList.length;
111114
this.defaultConfig = options?.defaultConfig;
112115
this.onMetaChange = options?.onMetaChange;
113-
makeAutoObservable(this, {
116+
makeAutoObservable<this, 'pristineInitialCharts'>(this, {
114117
visList: observable.shallow,
118+
// Identity distinguishes the untouched async-loading placeholder from
119+
// imported or edited charts; it must not be wrapped by MobX.
120+
pristineInitialCharts: false,
115121
allEncodings: computed.struct,
116122
filters: observable.ref,
117123
tableCollapsedHeaderMap: observable.ref,
@@ -388,7 +394,16 @@ export class VizSpecStore {
388394
}
389395

390396
setMeta(meta: IMutField[]) {
397+
const pristineInitialChart =
398+
this.visList.length === 1 && this.pristineInitialCharts.has(this.visList[0]) ? this.visList[0] : undefined;
391399
this.meta = meta;
400+
if (meta.length === 0) return;
401+
402+
this.pristineInitialCharts.clear();
403+
if (pristineInitialChart) {
404+
const { name, visId } = pristineInitialChart.now;
405+
this.visList[0] = create(newChart(meta, name ?? 'Chart 1', visId, this.defaultConfig));
406+
}
392407
}
393408

394409
setOnMetaChange(onMetaChange?: (fid: string, diffMeta: Partial<IMutField>) => void) {
@@ -400,19 +415,29 @@ export class VizSpecStore {
400415
}
401416

402417
resetVisualization(name = 'Chart 1') {
418+
const trackAsInitialPlaceholder = this.meta.length === 0 && this.pristineInitialCharts.size > 0;
403419
this.visList = [fromFields(this.meta, name, this.defaultConfig)];
420+
if (trackAsInitialPlaceholder) {
421+
this.pristineInitialCharts.clear();
422+
this.pristineInitialCharts.add(this.visList[0]);
423+
}
404424
this.createdVis = 1;
405425
}
406426

407427
addVisualization(defaultName?: string | ((index: number) => string)) {
428+
const trackAsInitialPlaceholder = this.meta.length === 0 && this.pristineInitialCharts.size > 0;
408429
const name = defaultName ? (typeof defaultName === 'function' ? defaultName(this.createdVis + 1) : defaultName) : 'Chart ' + (this.createdVis + 1);
409430
this.visList.push(fromFields(this.meta, name, this.defaultConfig));
431+
if (trackAsInitialPlaceholder) {
432+
this.pristineInitialCharts.add(this.visList[this.visList.length - 1]);
433+
}
410434
this.createdVis += 1;
411435
this.visIndex = this.visList.length - 1;
412436
}
413437

414438
removeVisualization(index: number) {
415439
if (this.visLength === 1) return;
440+
this.pristineInitialCharts.delete(this.visList[index]);
416441
if (this.visIndex >= index && this.visIndex > 0) this.visIndex -= 1;
417442
this.visList.splice(index, 1);
418443
}

0 commit comments

Comments
 (0)