Skip to content

Commit

Permalink
Share tanstack QueryClient between dashboard and IDE (#10431)
Browse files Browse the repository at this point in the history
* Share tanstack QueryClient between dashboard and IDE

Part of #10400.

* Lint

* Review: Use enso-common

* Remove outdated README

* Naming

* Fix

* Lint

* enso-common CODEOWNERS: GUI+Dashboard

* Review: Prepare for GUI to be run from cloud entry point

* Lint

* Lint

* Fix e2e tests

* Fix e2e tests in CI?

* Clean CI build

* Revert "Clean CI build"

This reverts commit 73f2fb7.

* Fix redundant dependency

* Work around a vue-query bug

* Lint

* fmt
  • Loading branch information
kazcw authored Jul 9, 2024
1 parent e4da96e commit bc92035
Show file tree
Hide file tree
Showing 13 changed files with 367 additions and 193 deletions.
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ Cargo.toml
# The data-link schema is owned by the libraries team
/app/ide-desktop/lib/dashboard/src/data/datalinkSchema.json @radeusgd @jdunkerley @GregoryTravis @AdRiley @marthasharkey
/app/ide-desktop/lib/dashboard/src/data/__tests__ @radeusgd @jdunkerley @GregoryTravis @AdRiley @marthasharkey @PabloBuchu @indiv0 @somebody1234

# GUI / Dashboard shared
/app/ide-desktop/lib/common @PabloBuchu @indiv0 @somebody1234 @MrFlashAccount @Frizi @farmaazon @vitvakatu @kazcw @AdRiley
1 change: 1 addition & 0 deletions app/gui2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@lezer/highlight": "^1.1.6",
"@noble/hashes": "^1.3.2",
"@open-rpc/client-js": "^1.8.1",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0",
"@vueuse/core": "^10.4.1",
"ag-grid-community": "^30.2.1",
"ag-grid-enterprise": "^30.2.1",
Expand Down
15 changes: 13 additions & 2 deletions app/gui2/src/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { baseConfig, configValue, mergeConfig } from '@/util/config'
import { urlParams } from '@/util/urlParams'
import * as vueQuery from '@tanstack/vue-query'
import { isOnLinux } from 'enso-common/src/detect'
import * as commonQuery from 'enso-common/src/queryClient'
import * as dashboard from 'enso-dashboard'
import { isDevMode } from 'shared/util/detect'
import { lazyVueInReact } from 'veaury'
import { type App } from 'vue'

import 'enso-dashboard/src/tailwind.css'
import type { EditorRunner } from '../../ide-desktop/lib/types/types'
Expand Down Expand Up @@ -46,8 +49,6 @@ window.addEventListener('resize', () => {
scamWarningHandle = window.setTimeout(printScamWarning, SCAM_WARNING_TIMEOUT)
})

const appRunner = lazyVueInReact(AsyncApp as any /* async VueComponent */) as EditorRunner

/** The entrypoint into the IDE. */
function main() {
/** Note: Signing out always redirects to `/`. It is impossible to make this work,
Expand All @@ -74,6 +75,15 @@ function main() {
const projectManagerUrl = config.engine.projectManagerUrl || PROJECT_MANAGER_URL
const ydocUrl = config.engine.ydocUrl === '' ? YDOC_SERVER_URL : config.engine.ydocUrl
const initialProjectName = config.startup.project || null
const queryClient = commonQuery.createQueryClient()

const registerPlugins = (app: App) => {
app.use(vueQuery.VueQueryPlugin, { queryClient })
}

const appRunner = lazyVueInReact(AsyncApp as any /* async VueComponent */, {
beforeVueAppMount: (app) => registerPlugins(app as App),
}) as EditorRunner

dashboard.run({
appRunner,
Expand All @@ -96,6 +106,7 @@ function main() {
}
}
},
queryClient,
})
}

Expand Down
7 changes: 0 additions & 7 deletions app/ide-desktop/lib/common/README.md

This file was deleted.

13 changes: 12 additions & 1 deletion app/ide-desktop/lib/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
"./src/buildUtils": "./src/buildUtils.js",
"./src/detect": "./src/detect.ts",
"./src/gtag": "./src/gtag.ts",
"./src/load": "./src/load.ts"
"./src/load": "./src/load.ts",
"./src/queryClient": "./src/queryClient.ts"
},
"peerDependencies": {
"@tanstack/query-core": "5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0"
},
"dependencies": {
"idb-keyval": "^6.2.1",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0",
"vue": "^3.4.19"
}
}
175 changes: 175 additions & 0 deletions app/ide-desktop/lib/common/src/queryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @file
*
* Tanstack Query client for Enso IDE and dashboard.
*/

import * as idbKeyval from 'idb-keyval'
import * as persistClientCore from '@tanstack/query-persist-client-core'
import * as queryCore from '@tanstack/query-core'
import * as vueQuery from './vueQuery'

declare module '@tanstack/query-core' {
/**
* Query client with additional methods.
*/
interface QueryClient {
/**
* Clear the cache stored in Tanstack Query and the persister storage.
* Please use this method with caution, as it will clear all cache data.
* Usually you should use `queryClient.invalidateQueries` instead.
*/
readonly clearWithPersister: () => Promise<void>
/**
* Clear the cache stored in the persister storage.
*/
readonly nukePersister: () => Promise<void>
}
/**
* Specifies the invalidation behavior of a mutation.
*/
interface Register {
readonly mutationMeta: {
/**
* List of query keys to invalidate when the mutation succeeds.
*/
readonly invalidates?: queryCore.QueryKey[]
/**
* List of query keys to await invalidation before the mutation is considered successful.
*
* If `true`, all `invalidates` are awaited.
*
* If `false`, no invalidations are awaited.
*
* You can also provide an array of query keys to await.
*
* Queries that are not listed in invalidates will be ignored.
* @default false
*/
readonly awaitInvalidates?: queryCore.QueryKey[] | boolean
}

readonly queryMeta: {
/**
* Whether to persist the query cache in the storage. Defaults to `true`.
* Use `false` to disable persistence for a specific query, for example for
* a sensitive data or data that can't be persisted, e.g. class instances.
* @default true
*/
readonly persist?: boolean
}
}
}

/** Query Client type suitable for shared use in React and Vue. */
export type QueryClient = vueQuery.QueryClient

// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_QUERY_STALE_TIME_MS = 2 * 60 * 1000
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days

const DEFAULT_BUSTER = 'v1.1'

/**
* Create a new Tanstack Query client.
*/
export function createQueryClient(): QueryClient {
const store = idbKeyval.createStore('enso', 'query-persist-cache')
queryCore.onlineManager.setOnline(navigator.onLine)

const persister = persistClientCore.experimental_createPersister({
storage: {
getItem: key => idbKeyval.get<persistClientCore.PersistedQuery>(key, store),
setItem: (key, value) => idbKeyval.set(key, value, store),
removeItem: key => idbKeyval.del(key, store),
},
// Prefer online first and don't rely on the local cache if user is online
// fallback to the local cache only if the user is offline
maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS,
buster: DEFAULT_BUSTER,
filters: { predicate: query => query.meta?.persist !== false },
prefix: 'enso:query-persist:',
serialize: persistedQuery => persistedQuery,
deserialize: persistedQuery => persistedQuery,
})

const queryClient: QueryClient = new vueQuery.QueryClient({
mutationCache: new queryCore.MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
const shouldAwaitInvalidates = mutation.meta?.awaitInvalidates ?? false
const invalidates = mutation.meta?.invalidates ?? []
const invalidatesToAwait = (() => {
if (Array.isArray(shouldAwaitInvalidates)) {
return shouldAwaitInvalidates
} else {
return shouldAwaitInvalidates ? invalidates : []
}
})()
const invalidatesToIgnore = invalidates.filter(
queryKey => !invalidatesToAwait.includes(queryKey)
)

for (const queryKey of invalidatesToIgnore) {
void queryClient.invalidateQueries({
predicate: query => queryCore.matchQuery({ queryKey }, query),
})
}

if (invalidatesToAwait.length > 0) {
// eslint-disable-next-line no-restricted-syntax
return Promise.all(
invalidatesToAwait.map(queryKey =>
queryClient.invalidateQueries({
predicate: query => queryCore.matchQuery({ queryKey }, query),
})
)
)
}
},
}),
defaultOptions: {
queries: {
persister,
refetchOnReconnect: 'always',
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
retry: (failureCount, error: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const statusesToIgnore = [401, 403, 404]
const errorStatus =
typeof error === 'object' &&
error != null &&
'status' in error &&
typeof error.status === 'number'
? error.status
: -1

if (statusesToIgnore.includes(errorStatus)) {
return false
} else {
return failureCount < 3
}
},
},
},
})

Object.defineProperty(queryClient, 'nukePersister', {
value: () => idbKeyval.clear(store),
enumerable: false,
configurable: false,
writable: false,
})

Object.defineProperty(queryClient, 'clearWithPersister', {
value: () => {
queryClient.clear()
return queryClient.nukePersister()
},
enumerable: false,
configurable: false,
writable: false,
})

return queryClient
}
99 changes: 99 additions & 0 deletions app/ide-desktop/lib/common/src/vueQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/** @file QueryClient based on the '@tanstack/vue-query' implementation. */

import * as vueQuery from '@tanstack/vue-query'
import * as queryCore from '@tanstack/query-core'
import * as vue from 'vue'

/** The QueryClient from vue-query, but with immediate query invalidation. */
export class QueryClient extends vueQuery.QueryClient {
/** Like the `invalidateQueries` method of `vueQuery.QueryClient`, but invalidates queries immediately. */
// Workaround for https://github.com/TanStack/query/issues/7694
override invalidateQueries(
filters: MaybeRefDeep<queryCore.InvalidateQueryFilters> = {},
options: MaybeRefDeep<queryCore.InvalidateOptions> = {}
): Promise<void> {
const filtersValue = cloneDeepUnref(filters)
const optionsValue = cloneDeepUnref(options)
queryCore.notifyManager.batch(() => {
this.getQueryCache()
.findAll(filtersValue)
.forEach(query => {
query.invalidate()
})
})
if (filtersValue.refetchType === 'none') {
return Promise.resolve()
} else {
const refetchType = filtersValue.refetchType
return vue.nextTick(() =>
queryCore.notifyManager.batch(() => {
const refetchFilters: queryCore.RefetchQueryFilters = {
...filtersValue,
type: refetchType ?? filtersValue.type ?? 'active',
}
return this.refetchQueries(refetchFilters, optionsValue)
})
)
}
}
}

/* eslint-disable */

function isPlainObject(value: unknown): value is Object {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false
}

const prototype = Object.getPrototypeOf(value)
return prototype === null || prototype === Object.prototype
}

function cloneDeep<T>(
value: MaybeRefDeep<T>,
customize?: (val: MaybeRefDeep<T>) => T | undefined
): T {
if (customize) {
const result = customize(value)
// If it's a ref of undefined, return undefined
if (result === undefined && vue.isRef(value)) {
return result as T
}
if (result !== undefined) {
return result
}
}

if (Array.isArray(value)) {
return value.map(val => cloneDeep(val, customize)) as unknown as T
}

if (typeof value === 'object' && isPlainObject(value)) {
const entries = Object.entries(value).map(([key, val]) => [key, cloneDeep(val, customize)])
return Object.fromEntries(entries)
}

return value as T
}

function cloneDeepUnref<T>(obj: MaybeRefDeep<T>): T {
return cloneDeep(obj, val => {
if (vue.isRef(val)) {
return cloneDeepUnref(vue.unref(val))
}

return undefined
})
}

type MaybeRefDeep<T> = vue.MaybeRef<
T extends Function
? T
: T extends object
? {
[Property in keyof T]: MaybeRefDeep<T[Property]>
}
: T
>

/* eslint-enable */
3 changes: 1 addition & 2 deletions app/ide-desktop/lib/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@
"@monaco-editor/react": "4.6.0",
"@sentry/react": "^7.74.0",
"@tanstack/react-query": "5.45.1",
"@tanstack/query-persist-client-core": "5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0",
"ajv": "^8.12.0",
"clsx": "^2.1.1",
"enso-assets": "workspace:*",
"enso-common": "workspace:*",
"idb-keyval": "6.2.1",
"is-network-error": "^1.0.1",
"monaco-editor": "0.48.0",
"react": "^18.3.1",
Expand Down
1 change: 1 addition & 0 deletions app/ide-desktop/lib/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export interface AppProps {
readonly appRunner: types.EditorRunner | null
readonly portalRoot: Element
readonly httpClient: HttpClient
readonly queryClient: reactQuery.QueryClient
}

/** Component called by the parent module, returning the root React component for this
Expand Down
3 changes: 3 additions & 0 deletions app/ide-desktop/lib/dashboard/src/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/** @file Entry point into the cloud dashboard. */
import * as commonQuery from 'enso-common/src/queryClient'

import '#/tailwind.css'

import * as main from '#/index'
Expand Down Expand Up @@ -27,4 +29,5 @@ main.run({
projectManagerUrl: null,
ydocUrl: null,
appRunner: testAppRunner.TestAppRunner,
queryClient: commonQuery.createQueryClient(),
})
Loading

0 comments on commit bc92035

Please sign in to comment.