diff --git a/libs/isograph-react/src/core/IsographEnvironment.ts b/libs/isograph-react/src/core/IsographEnvironment.ts index 0f2b2199d..d90907d50 100644 --- a/libs/isograph-react/src/core/IsographEnvironment.ts +++ b/libs/isograph-react/src/core/IsographEnvironment.ts @@ -8,6 +8,7 @@ import { } from './FragmentReference'; import { RetainedQuery } from './garbageCollection'; import { LogFunction, WrappedLogFunction } from './logging'; +import { createOptimisticProxy, type OptimisticLayer } from './optimisticProxy'; import { PromiseWrapper, wrapPromise } from './PromiseWrapper'; import { WithEncounteredRecords } from './read'; import type { ReaderAst, StartUpdate } from './reader'; @@ -52,6 +53,8 @@ export type CacheMap = { [index: string]: ParentCache }; export type IsographEnvironment = { readonly store: IsographStore; + readonly optimisticLayer: OptimisticLayer; + readonly optimisticStore: OptimisticLayer; readonly networkFunction: IsographNetworkFunction; readonly missingFieldHandler: MissingFieldHandler | null; readonly componentCache: FieldCache>; @@ -134,8 +137,11 @@ export function createIsographEnvironment( logFunction?.({ kind: 'EnvironmentCreated', }); + let optimisticLayer: OptimisticLayer = {}; return { store, + optimisticLayer, + optimisticStore: createOptimisticProxy(store, optimisticLayer), networkFunction, missingFieldHandler: missingFieldHandler ?? null, componentCache: {}, diff --git a/libs/isograph-react/src/core/optimisticProxy.ts b/libs/isograph-react/src/core/optimisticProxy.ts new file mode 100644 index 000000000..c3366a0dc --- /dev/null +++ b/libs/isograph-react/src/core/optimisticProxy.ts @@ -0,0 +1,124 @@ +import type { + DataId, + IsographEnvironment, + IsographStore, + StoreRecord, + TypeName, +} from './IsographEnvironment'; + +function createLayerProxy( + object: { + [key: string]: T | null; + }, + optimisticObject: { + [key: string]: T | null; + }, + getter: ( + value: T | null | undefined, + optimisticValue: T | undefined, + p: string, + ) => T | null | undefined, +): { + [key: string]: T | null; +} { + return new Proxy(optimisticObject, { + get(target, p: string) { + let optimisticValue = target[p]; + + if (optimisticValue === null) { + return optimisticValue; + } + + const value = object[p]; + + if (optimisticValue === undefined && value == null) { + return value; + } + + return getter(value, optimisticValue, p); + }, + has(target, p) { + return Reflect.has(target, p) || Reflect.has(object, p); + }, + ownKeys(target) { + const merged = { + ...object, + ...target, + }; + return Reflect.ownKeys(merged); + }, + set(target, p: string, value: any) { + return Reflect.set(target, p, value); + }, + getOwnPropertyDescriptor(target, p: string) { + return ( + Reflect.getOwnPropertyDescriptor(target, p) ?? + Reflect.getOwnPropertyDescriptor(object, p) + ); + }, + }); +} + +export function createOptimisticProxy( + store: IsographStore, + optimisticLayer: OptimisticLayer, +): OptimisticLayer { + return createLayerProxy( + store, + optimisticLayer, + (recordsById, optimisticRecordsById, p) => { + optimisticRecordsById = optimisticLayer[p] ??= {}; + return createLayerProxy( + recordsById ?? {}, + optimisticRecordsById, + (storeRecord, optimisticStoreRecord, p) => { + optimisticStoreRecord = optimisticRecordsById[p] ??= {}; + return createLayerProxy( + storeRecord ?? {}, + optimisticStoreRecord, + (value, optimisticValue) => + optimisticValue === undefined ? value : optimisticValue, + ); + }, + ); + }, + ); +} + +export type OptimisticLayer = { + [index: TypeName]: { + [index: DataId]: StoreRecord | null; + } | null; +}; + +export function mergeOptimisticLayer(environment: IsographEnvironment): void { + for (const [typeName, patchById] of Object.entries( + environment.optimisticLayer, + )) { + let recordById = environment.store[typeName]; + + if (patchById === null) { + environment.store[typeName] = null; + continue; + } + recordById = environment.store[typeName] ??= {}; + + for (const [recordId, patch] of Object.entries(patchById)) { + const data = recordById[recordId]; + + if (patch == null || data == null) { + recordById[recordId] = patch; + } else { + Object.assign(data, patch); + } + } + } + + resetOptimisticLayer(environment); +} + +export function resetOptimisticLayer(environment: IsographEnvironment) { + for (const key in environment.optimisticLayer) { + delete environment.optimisticLayer[key]; + } +} diff --git a/libs/isograph-react/src/tests/optimisticProxy.test.ts b/libs/isograph-react/src/tests/optimisticProxy.test.ts new file mode 100644 index 000000000..1de2e0f66 --- /dev/null +++ b/libs/isograph-react/src/tests/optimisticProxy.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + createIsographEnvironment, + type IsographStore, +} from '../core/IsographEnvironment'; +import { mergeOptimisticLayer } from '../core/optimisticProxy'; + +describe('optimisticProxy', () => { + let environment: ReturnType; + + beforeEach(() => { + const store: IsographStore = { + Query: { + __ROOT: {}, + }, + Economist: { + 0: { + __typename: 'Economist', + id: '0', + name: 'Jeremy Bentham', + }, + }, + }; + const networkFunction = vi + .fn() + .mockRejectedValue(new Error('Fetch failed')); + environment = createIsographEnvironment(store, networkFunction); + }); + + test('is equal to store', () => { + expect(environment.optimisticStore.Economist?.[0]).toStrictEqual({ + __typename: 'Economist', + id: '0', + name: 'Jeremy Bentham', + }); + }); + + test('writes update proxy', () => { + environment.optimisticStore.Economist![0]!.name = 'Updated Jeremy Bentham'; + + expect(environment.optimisticStore.Economist![0]).toStrictEqual({ + __typename: 'Economist', + id: '0', + name: 'Updated Jeremy Bentham', + }); + }); + + test('writes update optimistic layer', () => { + environment.optimisticStore.Economist![0]!.name = 'Updated Jeremy Bentham'; + + expect(environment.optimisticLayer).toStrictEqual({ + Economist: { + 0: { name: 'Updated Jeremy Bentham' }, + }, + }); + }); + + test('writes keep store intact', () => { + environment.optimisticStore.Economist![0]!.name = 'Updated Jeremy Bentham'; + + expect(environment.store.Economist?.[0]).toStrictEqual({ + __typename: 'Economist', + id: '0', + name: 'Jeremy Bentham', + }); + }); + + test('reads from optimistic layer if record is undefined', () => { + environment.optimisticLayer.Economist = { + 1: { + __typename: 'Economist', + id: '1', + name: 'John Stuart Mill', + }, + }; + + expect(environment.optimisticStore.Economist![1]).toStrictEqual({ + __typename: 'Economist', + id: '1', + name: 'John Stuart Mill', + }); + }); + + describe('mergeOptimisticLayer', () => { + test('merges optimistic layer with store', () => { + environment.optimisticStore.Economist![0]!.name = + 'Updated Jeremy Bentham'; + + mergeOptimisticLayer(environment); + expect(environment.optimisticLayer).toStrictEqual({}); + expect(environment.optimisticStore.Economist?.[0]).toStrictEqual({ + __typename: 'Economist', + id: '0', + name: 'Updated Jeremy Bentham', + }); + }); + }); +});