Skip to content

Commit 34bba0e

Browse files
[sync] T2396 fix duplicate field schema refresh after realtime updates (#1488) (#2800)
Synced from teableio/teable-ee@02c505a Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent 889cda1 commit 34bba0e

3 files changed

Lines changed: 329 additions & 10 deletions

File tree

packages/sdk/src/context/use-instances/use-instances.spec.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,166 @@ describe('useInstances hook', () => {
349349
expect(createSubscribeQuery).toHaveBeenCalledTimes(2);
350350
});
351351

352+
it('recreates field queries on schema-driven setField presence with fieldIds', () => {
353+
const { connection, createSubscribeQuery, presenceController, collection, queryParams } =
354+
createMockConnection({
355+
collection: 'fld_tblSchemaRefresh07',
356+
});
357+
358+
renderHook(
359+
() =>
360+
useInstances({
361+
...mockProps,
362+
collection,
363+
queryParams,
364+
}),
365+
{
366+
wrapper: createUseInstancesWrap({ ...mockAppContext, connection }),
367+
}
368+
);
369+
370+
expect(createSubscribeQuery).toHaveBeenCalledTimes(1);
371+
372+
act(() => {
373+
presenceController.emitReceive([
374+
{
375+
actionKey: 'setField',
376+
payload: {
377+
tableId: 'tblSchemaRefresh07',
378+
field: {
379+
id: 'fldSchemaRefresh07',
380+
},
381+
fieldIds: ['fldSchemaRefresh07'],
382+
},
383+
},
384+
]);
385+
});
386+
387+
expect(createSubscribeQuery).toHaveBeenCalledTimes(2);
388+
});
389+
390+
it('preserves field instances during schema refresh until the replacement query is ready', async () => {
391+
const presenceController = createMockPresence();
392+
const staleDoc = createMockDoc({
393+
data: { id: 'fldOld', name: 'Old Field' },
394+
collection: 'fld_tblSchemaRefresh09',
395+
id: 'fldOld',
396+
});
397+
const freshDoc = createMockDoc({
398+
data: { id: 'fldNew', name: 'New Field' },
399+
collection: 'fld_tblSchemaRefresh09',
400+
id: 'fldNew',
401+
});
402+
const queryListeners = new Map<string, Array<(...args: unknown[]) => void>>();
403+
const createQuery = (results: any[], ready: boolean) =>
404+
({
405+
collection: 'fld_tblSchemaRefresh09',
406+
query: {},
407+
results,
408+
ready,
409+
sent: true,
410+
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
411+
const listeners = queryListeners.get(event) ?? [];
412+
listeners.push(cb);
413+
queryListeners.set(event, listeners);
414+
}),
415+
once: vi.fn(),
416+
removeAllListeners: vi.fn(),
417+
removeListener: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
418+
const listeners = queryListeners.get(event) ?? [];
419+
queryListeners.set(
420+
event,
421+
listeners.filter((listener) => listener !== cb)
422+
);
423+
}),
424+
destroy: vi.fn((cb?: () => void) => cb?.()),
425+
}) as unknown as Query<any>;
426+
427+
const queries = [createQuery([staleDoc], true), createQuery([freshDoc], false)];
428+
const createSubscribeQuery = vi.fn(() => queries.shift() as Query<any>);
429+
const connection = {
430+
createSubscribeQuery,
431+
getPresence: vi.fn(() => presenceController.presence),
432+
} as any;
433+
434+
const { result } = renderHook(
435+
() =>
436+
useInstances({
437+
...mockProps,
438+
collection: 'fld_tblSchemaRefresh09',
439+
}),
440+
{
441+
wrapper: createUseInstancesWrap({ ...mockAppContext, connection }),
442+
}
443+
);
444+
445+
expect(result.current.instances[0]?.doc).toBe(staleDoc);
446+
447+
await act(async () => {
448+
presenceController.emitReceive([
449+
{
450+
actionKey: 'setField',
451+
payload: {
452+
tableId: 'tblSchemaRefresh09',
453+
field: {
454+
id: 'fldSchemaRefresh09',
455+
},
456+
fieldIds: ['fldSchemaRefresh09'],
457+
},
458+
},
459+
]);
460+
await Promise.resolve();
461+
});
462+
463+
expect(createSubscribeQuery).toHaveBeenCalledTimes(2);
464+
expect(result.current.instances[0]?.doc).toBe(staleDoc);
465+
466+
act(() => {
467+
const readyListeners = queryListeners.get('ready') ?? [];
468+
readyListeners.forEach((listener) => listener());
469+
});
470+
471+
expect(result.current.instances[0]?.doc).toBe(freshDoc);
472+
});
473+
474+
it('recreates view queries on schema-driven setField presence with fieldIds', () => {
475+
const { connection, createSubscribeQuery, presenceController, collection, queryParams } =
476+
createMockConnection({
477+
collection: 'viw_tblSchemaRefresh08',
478+
});
479+
480+
renderHook(
481+
() =>
482+
useInstances({
483+
...mockProps,
484+
collection,
485+
queryParams,
486+
}),
487+
{
488+
wrapper: createUseInstancesWrap({ ...mockAppContext, connection }),
489+
}
490+
);
491+
492+
expect(createSubscribeQuery).toHaveBeenCalledTimes(1);
493+
494+
act(() => {
495+
presenceController.emitReceive([
496+
{
497+
actionKey: 'setField',
498+
payload: {
499+
tableId: 'tblSchemaRefresh08',
500+
field: {
501+
id: 'fldSchemaRefresh08',
502+
},
503+
fieldIds: ['fldSchemaRefresh08'],
504+
},
505+
},
506+
]);
507+
});
508+
509+
expect(createSubscribeQuery).toHaveBeenCalledTimes(2);
510+
});
511+
352512
it('recreates record queries on schema-driven setField presence with updatedProperties', () => {
353513
const { connection, createSubscribeQuery, presenceController, collection, queryParams } =
354514
createMockConnection({

packages/sdk/src/context/use-instances/useInstances.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ const normalizeForKey = (value: any): any => {
9191
return value;
9292
};
9393

94+
const makeQueryScopeKey = (collection: string, queryParams: unknown) =>
95+
`${collection}|${JSON.stringify(normalizeForKey(queryParams))}`;
96+
9497
const makeQueryKey = (collection: string, queryParams: unknown, refreshToken = 0) =>
95-
`${collection}|${JSON.stringify(normalizeForKey(queryParams))}|refresh:${refreshToken}`;
98+
`${makeQueryScopeKey(collection, queryParams)}|refresh:${refreshToken}`;
9699

97100
const acquireQuery = <T>(
98101
collection: string,
@@ -124,9 +127,12 @@ const releaseQuery = (key?: string, cb?: () => void) => {
124127
cb?.();
125128
};
126129

127-
const getRecordCollectionTableId = (collection: string): string | undefined => {
130+
const getSchemaRefreshCollectionTableId = (collection: string): string | undefined => {
128131
const [prefix, tableId] = collection.split('_');
129-
if (prefix !== IdPrefix.Record || !tableId) {
132+
if (
133+
(prefix !== IdPrefix.Record && prefix !== IdPrefix.Field && prefix !== IdPrefix.View) ||
134+
!tableId
135+
) {
130136
return undefined;
131137
}
132138
return tableId;
@@ -201,10 +207,12 @@ export function useInstances<T, R extends { id: string }>({
201207
initData,
202208
}: IUseInstancesProps<T, R>): IInstanceState<R> {
203209
const { connection, connected } = useConnection();
204-
const recordCollectionTableId = getRecordCollectionTableId(collection);
210+
const schemaRefreshCollectionTableId = getSchemaRefreshCollectionTableId(collection);
205211
const [query, setQuery] = useState<Query<T>>();
206212
const [schemaRefreshToken, setSchemaRefreshToken] = useState(0);
207213
const currentKeyRef = useRef<string>();
214+
const currentScopeKeyRef = useRef<string>();
215+
const shouldClearInstancesOnQueryCleanupRef = useRef(true);
208216
const [instances, dispatch] = useReducer(
209217
(state: IInstanceState<R>, action: IInstanceAction<T>) =>
210218
instanceReducer(state, action, factory),
@@ -218,12 +226,12 @@ export function useInstances<T, R extends { id: string }>({
218226
const lastConnectionRef = useRef<typeof connection>();
219227

220228
useEffect(() => {
221-
if (!connection || !recordCollectionTableId) {
229+
if (!connection || !schemaRefreshCollectionTableId) {
222230
return;
223231
}
224232

225233
const presence: Presence = connection.getPresence(
226-
getActionTriggerChannel(recordCollectionTableId)
234+
getActionTriggerChannel(schemaRefreshCollectionTableId)
227235
);
228236
if (!presence.subscribed) {
229237
presence.subscribe((error) => {
@@ -234,7 +242,7 @@ export function useInstances<T, R extends { id: string }>({
234242
}
235243

236244
const receiveListener = (_id: string, batch: unknown) => {
237-
if (!isSchemaRefreshAction(recordCollectionTableId, batch)) {
245+
if (!isSchemaRefreshAction(schemaRefreshCollectionTableId, batch)) {
238246
return;
239247
}
240248
setSchemaRefreshToken((current) => current + 1);
@@ -249,7 +257,7 @@ export function useInstances<T, R extends { id: string }>({
249257
presence.destroy();
250258
}
251259
};
252-
}, [connection, recordCollectionTableId]);
260+
}, [connection, schemaRefreshCollectionTableId]);
253261

254262
const handleReady = useCallback((query: Query<T>) => {
255263
console.log(
@@ -315,12 +323,15 @@ export function useInstances<T, R extends { id: string }>({
315323

316324
useEffect(() => {
317325
if (!collection || !connection) {
326+
shouldClearInstancesOnQueryCleanupRef.current = true;
327+
currentScopeKeyRef.current = undefined;
318328
setQuery(undefined);
319329
return;
320330
}
321331

322332
// Compute normalized key and short-circuit if unchanged and connection didn't change
323333
const nextKey = makeQueryKey(collection, queryParams, schemaRefreshToken);
334+
const nextScopeKey = makeQueryScopeKey(collection, queryParams);
324335
const connectionChanged = lastConnectionRef.current !== connection;
325336
if (!connectionChanged && currentKeyRef.current === nextKey && preQueryRef.current) {
326337
// Ensure state holds the existing query instance without re-acquiring
@@ -329,12 +340,16 @@ export function useInstances<T, R extends { id: string }>({
329340
}
330341

331342
const previousKey = currentKeyRef.current;
343+
const previousScopeKey = currentScopeKeyRef.current;
344+
shouldClearInstancesOnQueryCleanupRef.current =
345+
connectionChanged || previousScopeKey !== nextScopeKey;
332346
if (previousKey && (connectionChanged || previousKey !== nextKey)) {
333347
releaseQuery(previousKey, () => opListeners.current.clear());
334348
}
335349

336350
const { key, query } = acquireQuery<T>(collection, connection, queryParams, schemaRefreshToken);
337351
currentKeyRef.current = key;
352+
currentScopeKeyRef.current = nextScopeKey;
338353
preQueryRef.current = query as Query<T>;
339354
lastConnectionRef.current = connection;
340355
setQuery(query as Query<T>);
@@ -349,8 +364,10 @@ export function useInstances<T, R extends { id: string }>({
349364
if (!hasQuery) {
350365
return;
351366
}
352-
// for easy component refresh clean data when switch & loading
353-
dispatch({ type: 'clear' });
367+
// Preserve current instances during same-scope schema refreshes so grids do not flash blank.
368+
if (shouldClearInstancesOnQueryCleanupRef.current) {
369+
dispatch({ type: 'clear' });
370+
}
354371
// release cached query on unmount or when switching queries
355372
releaseQuery(keyAtMount, () => listeners.clear());
356373
};

0 commit comments

Comments
 (0)