From e665b3815db1fe4e4f8003ea2752b9de430fe84f Mon Sep 17 00:00:00 2001 From: pa Date: Fri, 6 Mar 2026 18:14:24 +0900 Subject: [PATCH] add @tanstack/query --- package-lock.json | 144 +++++++++-- package.json | 2 + src/api/__tests__/entityQuerySync.test.js | 113 +++++++++ src/api/__tests__/favoriteQuerySync.test.js | 91 +++++++ src/api/__tests__/friendQuerySync.test.js | 72 ++++++ src/api/__tests__/groupQuerySync.test.js | 101 ++++++++ src/api/__tests__/mediaQuerySync.test.js | 100 ++++++++ src/api/avatar.js | 58 ++++- src/api/favorite.js | 76 ++++++ src/api/friend.js | 36 +++ src/api/group.js | 130 ++++++++-- src/api/instance.js | 54 ++-- src/api/inventory.js | 41 ++++ src/api/misc.js | 21 ++ src/api/user.js | 40 +-- src/api/vrcPlusIcon.js | 30 +++ src/api/vrcPlusImage.js | 44 ++++ src/api/world.js | 70 ++++-- src/app.js | 4 +- .../dialogs/AvatarDialog/AvatarDialog.vue | 6 +- .../dialogs/GroupDialog/GroupDialog.vue | 20 +- .../dialogs/UserDialog/UserDialog.vue | 97 +++++--- .../dialogs/WorldDialog/WorldDialog.vue | 3 +- src/query/__tests__/entityCache.test.js | 133 ++++++++++ src/query/__tests__/keys.test.js | 63 +++++ src/query/__tests__/policies.test.js | 137 +++++++++++ src/query/client.js | 11 + src/query/entityCache.js | 230 ++++++++++++++++++ src/query/index.js | 14 ++ src/query/keys.js | 149 ++++++++++++ src/query/policies.js | 97 ++++++++ src/query/useEntityQueries.js | 55 +++++ src/stores/avatar.js | 13 +- src/stores/favorite.js | 12 +- src/stores/friend.js | 2 +- src/stores/gallery.js | 22 +- src/stores/group.js | 79 +++--- src/stores/instance.js | 2 + src/stores/user.js | 5 +- src/stores/world.js | 26 +- 40 files changed, 2171 insertions(+), 232 deletions(-) create mode 100644 src/api/__tests__/entityQuerySync.test.js create mode 100644 src/api/__tests__/favoriteQuerySync.test.js create mode 100644 src/api/__tests__/friendQuerySync.test.js create mode 100644 src/api/__tests__/groupQuerySync.test.js create mode 100644 src/api/__tests__/mediaQuerySync.test.js create mode 100644 src/query/__tests__/entityCache.test.js create mode 100644 src/query/__tests__/keys.test.js create mode 100644 src/query/__tests__/policies.test.js create mode 100644 src/query/client.js create mode 100644 src/query/entityCache.js create mode 100644 src/query/index.js create mode 100644 src/query/keys.js create mode 100644 src/query/policies.js create mode 100644 src/query/useEntityQueries.js diff --git a/package-lock.json b/package-lock.json index 7b2362c8..fe368561 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "VRCX", "license": "MIT", "dependencies": { + "@tanstack/vue-query": "^5.92.9", "hazardous": "^0.3.0", "node-api-dotnet": "^0.9.19" }, @@ -27,6 +28,7 @@ "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.90.21", "@tanstack/vue-table": "^8.21.3", "@tanstack/vue-virtual": "^3.13.19", "@types/node": "^25.3.3", @@ -426,7 +428,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -436,7 +437,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -470,7 +470,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -582,7 +581,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2479,7 +2477,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4084,6 +4081,49 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -4109,6 +4149,63 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/vue-query": { + "version": "5.92.9", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.92.9.tgz", + "integrity": "sha512-jjAZcqKveyX0C4w/6zUqbnqk/XzuxNWaFsWjGTJWULVFizUNeLGME2gf9vVSDclIyiBhR13oZJPPs6fJgfpIJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/query-core": "5.90.20", + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@vue/composition-api": "^1.1.2", + "vue": "^2.6.0 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@tanstack/vue-query/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@tanstack/vue-query/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@tanstack/vue-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", @@ -4627,7 +4724,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -4641,7 +4737,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.29", @@ -4652,7 +4747,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -4670,7 +4764,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4680,7 +4773,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.29", @@ -4727,7 +4819,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", - "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.29" @@ -4737,7 +4828,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -4748,7 +4838,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -4761,7 +4850,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.29", @@ -4775,7 +4863,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", - "dev": true, "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -6197,7 +6284,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -7033,7 +7119,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -7510,7 +7595,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/esutils": { @@ -9748,7 +9832,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -10312,7 +10395,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10370,7 +10452,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10578,6 +10659,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -10661,6 +10753,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11558,7 +11656,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12232,7 +12329,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -12679,7 +12776,6 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index 530f4207..f6960c7a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.90.21", "@tanstack/vue-table": "^8.21.3", "@tanstack/vue-virtual": "^3.13.19", "@types/node": "^25.3.3", @@ -189,6 +190,7 @@ } }, "dependencies": { + "@tanstack/vue-query": "^5.92.9", "hazardous": "^0.3.0", "node-api-dotnet": "^0.9.19" }, diff --git a/src/api/__tests__/entityQuerySync.test.js b/src/api/__tests__/entityQuerySync.test.js new file mode 100644 index 00000000..231301d2 --- /dev/null +++ b/src/api/__tests__/entityQuerySync.test.js @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockRequest = vi.fn(); +const mockPatchAndRefetchActiveQuery = vi.fn(() => Promise.resolve()); +const mockFetchWithEntityPolicy = vi.fn(); + +const mockApplyCurrentUser = vi.fn((json) => ({ id: json.id || 'usr_me', ...json })); +const mockApplyUser = vi.fn((json) => ({ ...json })); +const mockApplyWorld = vi.fn((json) => ({ ...json })); + +vi.mock('../../service/request', () => ({ + request: (...args) => mockRequest(...args) +})); + +vi.mock('../../stores', () => ({ + useUserStore: () => ({ + currentUser: { id: 'usr_me' }, + applyCurrentUser: mockApplyCurrentUser, + applyUser: mockApplyUser + }), + useWorldStore: () => ({ + applyWorld: mockApplyWorld + }) +})); + +vi.mock('../../query', () => ({ + entityQueryPolicies: { + user: { staleTime: 20000, gcTime: 90000, retry: 1, refetchOnWindowFocus: false }, + avatar: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false }, + world: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false }, + worldCollection: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false }, + instance: { staleTime: 0, gcTime: 10000, retry: 0, refetchOnWindowFocus: false } + }, + fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args), + patchAndRefetchActiveQuery: (...args) => + mockPatchAndRefetchActiveQuery(...args), + queryKeys: { + user: (userId) => ['user', userId], + avatar: (avatarId) => ['avatar', avatarId], + world: (worldId) => ['world', worldId], + worldsByUser: (params) => ['worlds', 'user', params.userId, params], + instance: (worldId, instanceId) => ['instance', worldId, instanceId] + } +})); + +import avatarRequest from '../avatar'; +import userRequest from '../user'; +import worldRequest from '../world'; + +describe('entity mutation query sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('saveCurrentUser patches and refetches active user query', async () => { + mockRequest.mockResolvedValue({ id: 'usr_me', status: 'active' }); + + await userRequest.saveCurrentUser({ status: 'active' }); + + expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['user', 'usr_me'] + }) + ); + }); + + test('saveAvatar patches and refetches active avatar query', async () => { + mockRequest.mockResolvedValue({ id: 'avtr_1', name: 'Avatar' }); + + await avatarRequest.saveAvatar({ id: 'avtr_1', name: 'Avatar' }); + + expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['avatar', 'avtr_1'] + }) + ); + }); + + test('saveWorld patches and refetches active world query', async () => { + mockRequest.mockResolvedValue({ id: 'wrld_1', name: 'World' }); + + await worldRequest.saveWorld({ id: 'wrld_1', name: 'World' }); + + expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['world', 'wrld_1'] + }) + ); + }); + + test('getCachedWorlds uses policy wrapper for world list data', async () => { + mockFetchWithEntityPolicy.mockResolvedValue({ + data: { + json: [{ id: 'wrld_1' }], + params: { userId: 'usr_me', n: 50, offset: 0 } + }, + cache: true + }); + + const args = await worldRequest.getCachedWorlds({ + userId: 'usr_me', + n: 50, + offset: 0, + sort: 'updated', + order: 'descending', + user: 'me', + releaseStatus: 'all' + }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalled(); + expect(args.cache).toBe(true); + }); +}); diff --git a/src/api/__tests__/favoriteQuerySync.test.js b/src/api/__tests__/favoriteQuerySync.test.js new file mode 100644 index 00000000..26b43ff6 --- /dev/null +++ b/src/api/__tests__/favoriteQuerySync.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockRequest = vi.fn(); +const mockFetchWithEntityPolicy = vi.fn(); +const mockInvalidateQueries = vi.fn().mockResolvedValue(); +const mockHandleFavoriteAdd = vi.fn(); +const mockHandleFavoriteDelete = vi.fn(); +const mockHandleFavoriteGroupClear = vi.fn(); + +vi.mock('../../service/request', () => ({ + request: (...args) => mockRequest(...args) +})); + +vi.mock('../../stores', () => ({ + useFavoriteStore: () => ({ + handleFavoriteAdd: (...args) => mockHandleFavoriteAdd(...args), + handleFavoriteDelete: (...args) => mockHandleFavoriteDelete(...args), + handleFavoriteGroupClear: (...args) => + mockHandleFavoriteGroupClear(...args) + }), + useUserStore: () => ({ + currentUser: { id: 'usr_me' } + }) +})); + +vi.mock('../../query', () => ({ + entityQueryPolicies: { + favoriteCollection: { + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + } + }, + fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args), + queryClient: { + invalidateQueries: (...args) => mockInvalidateQueries(...args) + }, + queryKeys: { + favoriteLimits: () => ['favorite', 'limits'], + favorites: (params) => ['favorite', 'items', params], + favoriteGroups: (params) => ['favorite', 'groups', params], + favoriteWorlds: (params) => ['favorite', 'worlds', params], + favoriteAvatars: (params) => ['favorite', 'avatars', params] + } +})); + +import favoriteRequest from '../favorite'; + +describe('favorite query sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('cached favorite reads go through fetchWithEntityPolicy', async () => { + mockFetchWithEntityPolicy.mockResolvedValue({ + data: { json: [], params: { n: 300, offset: 0 } }, + cache: true + }); + + const args = await favoriteRequest.getCachedFavorites({ + n: 300, + offset: 0 + }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalled(); + expect(args.cache).toBe(true); + }); + + test('favorite mutations invalidate active favorite queries', async () => { + mockRequest.mockResolvedValue({ ok: true }); + + await favoriteRequest.addFavorite({ type: 'world', favoriteId: 'wrld_1' }); + await favoriteRequest.deleteFavorite({ objectId: 'fav_1' }); + await favoriteRequest.saveFavoriteGroup({ + type: 'world', + group: 'worlds1', + displayName: 'Worlds' + }); + await favoriteRequest.clearFavoriteGroup({ + type: 'world', + group: 'worlds1' + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['favorite'], + refetchType: 'active' + }); + }); +}); diff --git a/src/api/__tests__/friendQuerySync.test.js b/src/api/__tests__/friendQuerySync.test.js new file mode 100644 index 00000000..d3a9c88f --- /dev/null +++ b/src/api/__tests__/friendQuerySync.test.js @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockRequest = vi.fn(); +const mockFetchWithEntityPolicy = vi.fn(); +const mockInvalidateQueries = vi.fn().mockResolvedValue(); +const mockApplyUser = vi.fn((json) => json); + +vi.mock('../../service/request', () => ({ + request: (...args) => mockRequest(...args) +})); + +vi.mock('../../stores/user', () => ({ + useUserStore: () => ({ + applyUser: (...args) => mockApplyUser(...args) + }) +})); + +vi.mock('../../query', () => ({ + entityQueryPolicies: { + friendList: { + staleTime: 20000, + gcTime: 90000, + retry: 1, + refetchOnWindowFocus: false + } + }, + fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args), + queryClient: { + invalidateQueries: (...args) => mockInvalidateQueries(...args) + }, + queryKeys: { + friends: (params) => ['friends', params] + } +})); + +import friendRequest from '../friend'; + +describe('friend query sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('getCachedFriends uses query policy wrapper', async () => { + mockFetchWithEntityPolicy.mockResolvedValue({ + data: { + json: [{ id: 'usr_1', displayName: 'A' }], + params: { n: 50, offset: 0 } + }, + cache: true + }); + + const args = await friendRequest.getCachedFriends({ n: 50, offset: 0 }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalled(); + expect(args.cache).toBe(true); + expect(args.json[0].id).toBe('usr_1'); + }); + + test('friend mutations invalidate active friends queries', async () => { + mockRequest.mockResolvedValue({ ok: true }); + + await friendRequest.sendFriendRequest({ userId: 'usr_1' }); + await friendRequest.cancelFriendRequest({ userId: 'usr_1' }); + await friendRequest.deleteFriend({ userId: 'usr_1' }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(3); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['friends'], + refetchType: 'active' + }); + }); +}); diff --git a/src/api/__tests__/groupQuerySync.test.js b/src/api/__tests__/groupQuerySync.test.js new file mode 100644 index 00000000..8fa841f1 --- /dev/null +++ b/src/api/__tests__/groupQuerySync.test.js @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockRequest = vi.fn(); +const mockFetchWithEntityPolicy = vi.fn(); +const mockInvalidateQueries = vi.fn().mockResolvedValue(); +const mockApplyGroup = vi.fn((json) => json); + +vi.mock('../../service/request', () => ({ + request: (...args) => mockRequest(...args) +})); + +vi.mock('../../stores', () => ({ + useGroupStore: () => ({ + applyGroup: (...args) => mockApplyGroup(...args) + }), + useUserStore: () => ({ + currentUser: { id: 'usr_me' } + }) +})); + +vi.mock('../../query', () => ({ + entityQueryPolicies: { + group: { + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }, + groupCollection: { + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + } + }, + fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args), + queryClient: { + invalidateQueries: (...args) => mockInvalidateQueries(...args) + }, + queryKeys: { + group: (groupId, includeRoles) => ['group', groupId, Boolean(includeRoles)], + groupPosts: (params) => ['group', params.groupId, 'posts', params], + groupMember: (params) => ['group', params.groupId, 'member', params.userId], + groupMembers: (params) => ['group', params.groupId, 'members', params], + groupGallery: (params) => ['group', params.groupId, 'gallery', params.galleryId, params], + groupCalendar: (groupId) => ['group', groupId, 'calendar'], + groupCalendarEvent: (params) => ['group', params.groupId, 'calendarEvent', params.eventId] + } +})); + +import groupRequest from '../group'; + +describe('group query sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('cached group resources use fetchWithEntityPolicy', async () => { + mockFetchWithEntityPolicy.mockResolvedValue({ + data: { json: [], params: { groupId: 'grp_1', n: 100, offset: 0 } }, + cache: true + }); + + const a = await groupRequest.getCachedGroupMembers({ + groupId: 'grp_1', + n: 100, + offset: 0, + sort: 'joinedAt:desc' + }); + const b = await groupRequest.getCachedGroupGallery({ + groupId: 'grp_1', + galleryId: 'gal_1', + n: 100, + offset: 0 + }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalledTimes(2); + expect(a.cache && b.cache).toBe(true); + }); + + test('group mutations invalidate scoped active group queries', async () => { + mockRequest.mockResolvedValue({ ok: true }); + + await groupRequest.setGroupRepresentation('grp_1', { + isRepresenting: true + }); + await groupRequest.deleteGroupPost({ + groupId: 'grp_1', + postId: 'post_1' + }); + await groupRequest.setGroupMemberProps('usr_me', 'grp_1', { + visibility: 'visible' + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(3); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['group', 'grp_1'], + refetchType: 'active' + }); + }); +}); diff --git a/src/api/__tests__/mediaQuerySync.test.js b/src/api/__tests__/mediaQuerySync.test.js new file mode 100644 index 00000000..b6e5ecae --- /dev/null +++ b/src/api/__tests__/mediaQuerySync.test.js @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockRequest = vi.fn(); +const mockFetchWithEntityPolicy = vi.fn(); +const mockInvalidateQueries = vi.fn().mockResolvedValue(); +const mockRemoveQueries = vi.fn(); + +vi.mock('../../service/request', () => ({ + request: (...args) => mockRequest(...args) +})); + +vi.mock('../../stores', () => ({ + useUserStore: () => ({ + currentUser: { id: 'usr_me' } + }) +})); + +vi.mock('../../query', () => ({ + entityQueryPolicies: { + galleryCollection: { + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }, + inventoryCollection: { + staleTime: 20000, + gcTime: 120000, + retry: 1, + refetchOnWindowFocus: false + }, + fileObject: { + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + } + }, + fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args), + queryClient: { + invalidateQueries: (...args) => mockInvalidateQueries(...args), + removeQueries: (...args) => mockRemoveQueries(...args) + }, + queryKeys: { + galleryFiles: (params) => ['gallery', 'files', params], + prints: (params) => ['gallery', 'prints', params], + print: (printId) => ['gallery', 'print', printId], + inventoryItems: (params) => ['inventory', 'items', params], + userInventoryItem: (params) => ['inventory', 'item', params.userId, params.inventoryId], + file: (fileId) => ['file', fileId] + } +})); + +import inventoryRequest from '../inventory'; +import miscRequest from '../misc'; +import vrcPlusIconRequest from '../vrcPlusIcon'; +import vrcPlusImageRequest from '../vrcPlusImage'; + +describe('media and inventory query sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('cached media/inventory reads go through fetchWithEntityPolicy', async () => { + mockFetchWithEntityPolicy.mockResolvedValue({ + data: { json: [], params: {} }, + cache: true + }); + + const a = await vrcPlusIconRequest.getCachedFileList({ tag: 'icon', n: 100 }); + const b = await vrcPlusImageRequest.getCachedPrints({ n: 100 }); + const c = await inventoryRequest.getCachedInventoryItems({ + n: 100, + offset: 0, + order: 'newest' + }); + const d = await miscRequest.getCachedFile({ fileId: 'file_1' }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalledTimes(4); + expect(a.cache && b.cache && c.cache && d.cache).toBe(true); + }); + + test('media mutations invalidate gallery queries and file delete removes file query', async () => { + mockRequest.mockResolvedValue({ ok: true }); + + await vrcPlusIconRequest.deleteFile('file_icon_1'); + await vrcPlusImageRequest.deletePrint('print_1'); + await vrcPlusImageRequest.uploadEmoji('img', { tag: 'emoji' }); + await miscRequest.deleteFile('file_misc_1'); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['gallery'], + refetchType: 'active' + }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: ['file', 'file_misc_1'], + exact: true + }); + }); +}); diff --git a/src/api/avatar.js b/src/api/avatar.js index 930b8281..a60eeba0 100644 --- a/src/api/avatar.js +++ b/src/api/avatar.js @@ -1,5 +1,11 @@ import { request } from '../service/request'; import { useUserStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + queryKeys +} from '../query'; const avatarReq = { /** @@ -17,6 +23,22 @@ const avatarReq = { }); }, + /** + * Fetch avatar from query cache if fresh. Otherwise, calls API. + * @param {{avatarId: string}} params + * @returns {Promise<{json: any, ref?: any, cache?: boolean, params: {avatarId: string}}>} + */ + getCachedAvatar(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.avatar(params.avatarId), + policy: entityQueryPolicies.avatar, + queryFn: () => avatarReq.getAvatar(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @type {import('../types/api/avatar').GetAvatars} */ @@ -46,6 +68,12 @@ const avatarReq = { json, params }; + patchAndRefetchActiveQuery({ + queryKey: queryKeys.avatar(params.id), + nextData: args + }).catch((err) => { + console.error('Failed to refresh avatar query after mutation:', err); + }); return args; }); }, @@ -64,7 +92,20 @@ const avatarReq = { json, params }; - userStore.applyCurrentUser(json); + const ref = userStore.applyCurrentUser(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.user(ref.id), + nextData: { + json, + params: { userId: ref.id }, + ref + } + }).catch((err) => { + console.error( + 'Failed to refresh current user query after avatar select:', + err + ); + }); return args; }); }, @@ -83,7 +124,20 @@ const avatarReq = { json, params }; - userStore.applyCurrentUser(json); + const ref = userStore.applyCurrentUser(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.user(ref.id), + nextData: { + json, + params: { userId: ref.id }, + ref + } + }).catch((err) => { + console.error( + 'Failed to refresh current user query after fallback avatar select:', + err + ); + }); return args; }); }, diff --git a/src/api/favorite.js b/src/api/favorite.js index ab479050..5474b2e7 100644 --- a/src/api/favorite.js +++ b/src/api/favorite.js @@ -1,10 +1,27 @@ import { useFavoriteStore, useUserStore } from '../stores'; import { request } from '../service/request'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; function getCurrentUserId() { return useUserStore().currentUser.id; } +function refetchActiveFavoriteQueries() { + queryClient + .invalidateQueries({ + queryKey: ['favorite'], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh favorite queries:', err); + }); +} + const favoriteReq = { getFavoriteLimits() { return request('auth/user/favoritelimits', { @@ -17,6 +34,17 @@ const favoriteReq = { }); }, + getCachedFavoriteLimits() { + return fetchWithEntityPolicy({ + queryKey: queryKeys.favoriteLimits(), + policy: entityQueryPolicies.favoriteCollection, + queryFn: () => favoriteReq.getFavoriteLimits() + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @type {import('../types/api/favorite').GetFavorites} */ @@ -33,6 +61,17 @@ const favoriteReq = { }); }, + getCachedFavorites(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.favorites(params), + policy: entityQueryPolicies.favoriteCollection, + queryFn: () => favoriteReq.getFavorites(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @type {import('../types/api/favorite').AddFavorite} */ @@ -46,6 +85,7 @@ const favoriteReq = { params }; useFavoriteStore().handleFavoriteAdd(args); + refetchActiveFavoriteQueries(); return args; }); }, @@ -63,6 +103,7 @@ const favoriteReq = { params }; useFavoriteStore().handleFavoriteDelete(params.objectId); + refetchActiveFavoriteQueries(); return args; }); }, @@ -84,6 +125,17 @@ const favoriteReq = { }); }, + getCachedFavoriteGroups(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.favoriteGroups(params), + policy: entityQueryPolicies.favoriteCollection, + queryFn: () => favoriteReq.getFavoriteGroups(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * * @param {{ type: string, group: string, displayName?: string, visibility?: string }} params group is a name @@ -101,6 +153,7 @@ const favoriteReq = { json, params }; + refetchActiveFavoriteQueries(); return args; }); }, @@ -125,6 +178,7 @@ const favoriteReq = { params }; useFavoriteStore().handleFavoriteGroupClear(args); + refetchActiveFavoriteQueries(); return args; }); }, @@ -145,6 +199,17 @@ const favoriteReq = { }); }, + getCachedFavoriteWorlds(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.favoriteWorlds(params), + policy: entityQueryPolicies.favoriteCollection, + queryFn: () => favoriteReq.getFavoriteWorlds(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @type {import('../types/api/favorite').GetFavoriteAvatars} */ @@ -159,6 +224,17 @@ const favoriteReq = { }; return args; }); + }, + + getCachedFavoriteAvatars(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.favoriteAvatars(params), + policy: entityQueryPolicies.favoriteCollection, + queryFn: () => favoriteReq.getFavoriteAvatars(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); } }; diff --git a/src/api/friend.js b/src/api/friend.js index c42130d3..29d0a53d 100644 --- a/src/api/friend.js +++ b/src/api/friend.js @@ -1,5 +1,22 @@ import { request } from '../service/request'; import { useUserStore } from '../stores/user'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; + +function refetchActiveFriendListQueries() { + queryClient + .invalidateQueries({ + queryKey: ['friends'], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh friend list queries:', err); + }); +} const friendReq = { /** @@ -27,6 +44,22 @@ const friendReq = { }); }, + /** + * Fetch friends from query cache if still fresh. Otherwise, calls API. + * @param {{ n: number, offset: number, offline?: boolean }} params + * @returns {Promise<{json: any, params: { n: number, offset: number, offline?: boolean }, cache?: boolean}>} + */ + getCachedFriends(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.friends(params), + policy: entityQueryPolicies.friendList, + queryFn: () => friendReq.getFriends(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @param {{ userId: string }} params * @returns {Promise<{json: any, params: { userId: string }}>} @@ -39,6 +72,7 @@ const friendReq = { json, params }; + refetchActiveFriendListQueries(); return args; }); }, @@ -55,6 +89,7 @@ const friendReq = { json, params }; + refetchActiveFriendListQueries(); return args; }); }, @@ -72,6 +107,7 @@ const friendReq = { json, params }; + refetchActiveFriendListQueries(); return args; }); }, diff --git a/src/api/group.js b/src/api/group.js index 6e99e8e2..9b95033f 100644 --- a/src/api/group.js +++ b/src/api/group.js @@ -1,9 +1,29 @@ import { useGroupStore, useUserStore } from '../stores'; import { request } from '../service/request'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; function getCurrentUserId() { return useUserStore().currentUser.id; } + +function refetchActiveGroupScope(groupId) { + if (!groupId) { + return; + } + queryClient + .invalidateQueries({ + queryKey: ['group', groupId], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh scoped group queries:', err); + }); +} const groupReq = { /** * @param {string} groupId @@ -20,6 +40,7 @@ const groupReq = { groupId, params }; + refetchActiveGroupScope(groupId); return args; }); }, @@ -52,6 +73,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -78,26 +100,18 @@ const groupReq = { * @return { Promise<{json: any, ref: any, cache?: boolean, params}> } */ getCachedGroup(params) { - const groupStore = useGroupStore(); - return new Promise((resolve, reject) => { - const ref = groupStore.cachedGroups.get(params.groupId); - if (typeof ref === 'undefined') { - groupReq - .getGroup(params) - .then((args) => { - args.ref = groupStore.applyGroup(args.json); - resolve(args); - }) - .catch(reject); - } else { - resolve({ - cache: true, - json: ref, - params, - ref - }); - } - }); + return fetchWithEntityPolicy({ + queryKey: queryKeys.group(params.groupId, params.includeRoles), + policy: entityQueryPolicies.group, + queryFn: () => groupReq.getGroup(params).then((args) => { + const groupStore = useGroupStore(); + args.ref = groupStore.applyGroup(args.json); + return args; + }) + }).then(({ data, cache }) => ({ + ...data, + cache + })); }, /** * @param {{ userId: string }} params @@ -141,6 +155,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -156,6 +171,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -195,6 +211,7 @@ const groupReq = { groupId, params }; + refetchActiveGroupScope(groupId); return args; }); }, @@ -217,6 +234,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -239,6 +257,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -273,6 +292,16 @@ const groupReq = { return args; }); }, + getCachedGroupPosts(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupPosts(params), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupPosts(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, editGroupPost(params) { return request(`groups/${params.groupId}/posts/${params.postId}`, { method: 'PUT', @@ -282,6 +311,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -294,6 +324,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -315,6 +346,16 @@ const groupReq = { return args; }); }, + getCachedGroupMember(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupMember(params), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupMember(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, /** * @param {{ * groupId: string, @@ -335,6 +376,16 @@ const groupReq = { return args; }); }, + getCachedGroupMembers(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupMembers(params), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupMembers(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, /** * @param {{ * groupId: string, @@ -370,6 +421,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -388,6 +440,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -427,6 +480,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -445,6 +499,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -456,6 +511,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -496,6 +552,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -510,7 +567,7 @@ const groupReq = { json, params }; - + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -526,6 +583,7 @@ const groupReq = { json, params }; + refetchActiveGroupScope(params.groupId); return args; }); }, @@ -698,6 +756,16 @@ const groupReq = { return args; }); }, + getCachedGroupGallery(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupGallery(params), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupGallery(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, getGroupCalendar(groupId) { return request(`calendar/${groupId}`, { @@ -712,6 +780,16 @@ const groupReq = { return args; }); }, + getCachedGroupCalendar(groupId) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupCalendar(groupId), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupCalendar(groupId) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, /** * @param {{ @@ -731,6 +809,16 @@ const groupReq = { return args; }); }, + getCachedGroupCalendarEvent(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.groupCalendarEvent(params), + policy: entityQueryPolicies.groupCollection, + queryFn: () => groupReq.getGroupCalendarEvent(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, /** * @type {import('../types/api/group').GetCalendars} diff --git a/src/api/instance.js b/src/api/instance.js index 4e996e1d..a2bd225a 100644 --- a/src/api/instance.js +++ b/src/api/instance.js @@ -3,6 +3,12 @@ import { toast } from 'vue-sonner'; import { i18n } from '../plugin/i18n'; import { request } from '../service/request'; import { useInstanceStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + queryKeys +} from '../query'; const instanceReq = { /** @@ -27,28 +33,14 @@ const instanceReq = { * @returns {Promise<{json: any, ref: any, cache?: boolean, params}>} */ getCachedInstance(params) { - const instanceStore = useInstanceStore(); - return new Promise((resolve, reject) => { - const ref = instanceStore.cachedInstances.get( - `${params.worldId}:${params.instanceId}` - ); - if (typeof ref === 'undefined') { - instanceReq - .getInstance(params) - .then((args) => { - args.ref = instanceStore.applyInstance(args.json); - resolve(args); - }) - .catch(reject); - } else { - resolve({ - cache: true, - json: ref, - params, - ref - }); - } - }); + return fetchWithEntityPolicy({ + queryKey: queryKeys.instance(params.worldId, params.instanceId), + policy: entityQueryPolicies.instance, + queryFn: () => instanceReq.getInstance(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); }, /** @@ -65,6 +57,15 @@ const instanceReq = { params }; args.ref = instanceStore.applyInstance(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.instance(args.ref.worldId, args.ref.instanceId), + nextData: args + }).catch((err) => { + console.error( + 'Failed to refresh instance query after instance creation:', + err + ); + }); return args; }); }, @@ -107,6 +108,15 @@ const instanceReq = { params }; args.ref = instanceStore.applyInstance(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.instance(args.ref.worldId, args.ref.instanceId), + nextData: args + }).catch((err) => { + console.error( + 'Failed to refresh instance query after short-name resolve:', + err + ); + }); return args; }); }, diff --git a/src/api/inventory.js b/src/api/inventory.js index b60a4087..7c476841 100644 --- a/src/api/inventory.js +++ b/src/api/inventory.js @@ -1,4 +1,21 @@ import { request } from '../service/request'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; + +function refetchActiveInventoryQueries() { + queryClient + .invalidateQueries({ + queryKey: ['inventory'], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh inventory queries:', err); + }); +} const inventoryReq = { /** @@ -20,6 +37,17 @@ const inventoryReq = { }); }, + getCachedUserInventoryItem(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.userInventoryItem(params), + policy: entityQueryPolicies.inventoryCollection, + queryFn: () => inventoryReq.getUserInventoryItem(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @param {{ inventoryId: string }} params * @returns {Promise<{json: any, params}>} @@ -54,6 +82,17 @@ const inventoryReq = { }); }, + getCachedInventoryItems(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.inventoryItems(params), + policy: entityQueryPolicies.inventoryCollection, + queryFn: () => inventoryReq.getInventoryItems(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + /** * @param {{ inventoryId: string }} params * @returns {Promise<{json: any, params}>} @@ -67,6 +106,7 @@ const inventoryReq = { json, params }; + refetchActiveInventoryQueries(); return args; }); }, @@ -102,6 +142,7 @@ const inventoryReq = { json, params }; + refetchActiveInventoryQueries(); return args; }); } diff --git a/src/api/misc.js b/src/api/misc.js index f91d96e7..b86d0b58 100644 --- a/src/api/misc.js +++ b/src/api/misc.js @@ -1,5 +1,11 @@ import { request } from '../service/request'; import { useUserStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; function getCurrentUserId() { return useUserStore().currentUser.id; @@ -18,6 +24,17 @@ const miscReq = { }); }, + getCachedFile(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.file(params.fileId), + policy: entityQueryPolicies.fileObject, + queryFn: () => miscReq.getFile(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + saveNote(params) { return request('userNotes', { method: 'POST', @@ -192,6 +209,10 @@ const miscReq = { json, fileId }; + queryClient.removeQueries({ + queryKey: queryKeys.file(fileId), + exact: true + }); return args; }); }, diff --git a/src/api/user.js b/src/api/user.js index eef88830..fbf2d773 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -1,5 +1,11 @@ import { request } from '../service/request'; import { useUserStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + queryKeys +} from '../query'; /** * @returns {string} @@ -39,26 +45,14 @@ const userReq = { * @type {import('../types/api/user').GetCachedUser} */ getCachedUser(params) { - const userStore = useUserStore(); - return new Promise((resolve, reject) => { - const ref = userStore.cachedUsers.get(params.userId); - if (typeof ref === 'undefined') { - userReq - .getUser(params) - .then((args) => { - args.ref = userStore.applyUser(args.json); - resolve(args); - }) - .catch(reject); - } else { - resolve({ - cache: true, - json: ref, - params, - ref - }); - } - }); + return fetchWithEntityPolicy({ + queryKey: queryKeys.user(params.userId), + policy: entityQueryPolicies.user, + queryFn: () => userReq.getUser(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); }, /** @@ -149,6 +143,12 @@ const userReq = { params, ref: userStore.applyCurrentUser(json) }; + patchAndRefetchActiveQuery({ + queryKey: queryKeys.user(args.ref.id), + nextData: args + }).catch((err) => { + console.error('Failed to refresh user query after mutation:', err); + }); return args; }); }, diff --git a/src/api/vrcPlusIcon.js b/src/api/vrcPlusIcon.js index 6b994093..453b577b 100644 --- a/src/api/vrcPlusIcon.js +++ b/src/api/vrcPlusIcon.js @@ -1,4 +1,21 @@ import { request } from '../service/request'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; + +function refetchActiveGalleryQueries() { + queryClient + .invalidateQueries({ + queryKey: ['gallery'], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh gallery queries:', err); + }); +} const VRCPlusIconsReq = { getFileList(params) { @@ -14,6 +31,17 @@ const VRCPlusIconsReq = { }); }, + getCachedFileList(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.galleryFiles(params), + policy: entityQueryPolicies.galleryCollection, + queryFn: () => VRCPlusIconsReq.getFileList(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + deleteFile(fileId) { return request(`file/${fileId}`, { method: 'DELETE' @@ -22,6 +50,7 @@ const VRCPlusIconsReq = { json, fileId }; + refetchActiveGalleryQueries(); return args; }); }, @@ -40,6 +69,7 @@ const VRCPlusIconsReq = { json, params }; + refetchActiveGalleryQueries(); return args; }); } diff --git a/src/api/vrcPlusImage.js b/src/api/vrcPlusImage.js index e4f3510e..dc421f97 100644 --- a/src/api/vrcPlusImage.js +++ b/src/api/vrcPlusImage.js @@ -1,9 +1,26 @@ import { request } from '../service/request'; import { useUserStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + queryClient, + queryKeys +} from '../query'; function getCurrentUserId() { return useUserStore().currentUser.id; } + +function refetchActiveGalleryQueries() { + queryClient + .invalidateQueries({ + queryKey: ['gallery'], + refetchType: 'active' + }) + .catch((err) => { + console.error('Failed to refresh gallery queries:', err); + }); +} const vrcPlusImageReq = { uploadGalleryImage(imageData) { const params = { @@ -19,6 +36,7 @@ const vrcPlusImageReq = { json, params }; + refetchActiveGalleryQueries(); return args; }); }, @@ -34,6 +52,7 @@ const vrcPlusImageReq = { json, params }; + refetchActiveGalleryQueries(); return args; }); }, @@ -51,6 +70,17 @@ const vrcPlusImageReq = { }); }, + getCachedPrints(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.prints(params), + policy: entityQueryPolicies.galleryCollection, + queryFn: () => vrcPlusImageReq.getPrints(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + deletePrint(printId) { return request(`prints/${printId}`, { method: 'DELETE' @@ -59,6 +89,7 @@ const vrcPlusImageReq = { json, printId }; + refetchActiveGalleryQueries(); return args; }); }, @@ -74,6 +105,7 @@ const vrcPlusImageReq = { json, params }; + refetchActiveGalleryQueries(); return args; }); }, @@ -90,6 +122,17 @@ const vrcPlusImageReq = { }); }, + getCachedPrint(params) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.print(params.printId), + policy: entityQueryPolicies.galleryCollection, + queryFn: () => vrcPlusImageReq.getPrint(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, + uploadEmoji(imageData, params) { return request('file/image', { uploadImage: true, @@ -101,6 +144,7 @@ const vrcPlusImageReq = { json, params }; + refetchActiveGalleryQueries(); return args; }); } diff --git a/src/api/world.js b/src/api/world.js index f19bea68..bc37296c 100644 --- a/src/api/world.js +++ b/src/api/world.js @@ -1,5 +1,11 @@ import { request } from '../service/request'; import { useWorldStore } from '../stores'; +import { + entityQueryPolicies, + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + queryKeys +} from '../query'; const worldReq = { /** @@ -24,26 +30,14 @@ const worldReq = { * @returns {Promise<{json: any, ref: any, cache?: boolean, params}>} */ getCachedWorld(params) { - const worldStore = useWorldStore(); - return new Promise((resolve, reject) => { - const ref = worldStore.cachedWorlds.get(params.worldId); - if (typeof ref === 'undefined') { - worldReq - .getWorld(params) - .then((args) => { - args.ref = worldStore.applyWorld(args.json); - resolve(args); - }) - .catch(reject); - } else { - resolve({ - cache: true, - json: ref, - params, - ref - }); - } - }); + return fetchWithEntityPolicy({ + queryKey: queryKeys.world(params.worldId), + policy: entityQueryPolicies.world, + queryFn: () => worldReq.getWorld(params) + }).then(({ data, cache }) => ({ + ...data, + cache + })); }, /** @@ -70,6 +64,24 @@ const worldReq = { return args; }); }, + /** + * @param {object} params + * @param {string} [option] + * @returns {Promise<{json: any, cache?: boolean, params: any, option?: string}>} + */ + getCachedWorlds(params, option) { + return fetchWithEntityPolicy({ + queryKey: queryKeys.worldsByUser({ + ...params, + option: option || '' + }), + policy: entityQueryPolicies.worldCollection, + queryFn: () => worldReq.getWorlds(params, option) + }).then(({ data, cache }) => ({ + ...data, + cache + })); + }, /** * @param {{worldId: string}} params * @returns {Promise<{json: any, params}>} @@ -100,6 +112,12 @@ const worldReq = { params }; args.ref = worldStore.applyWorld(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.world(args.ref.id), + nextData: args + }).catch((err) => { + console.error('Failed to refresh world query after mutation:', err); + }); return args; }); }, @@ -119,6 +137,12 @@ const worldReq = { params }; args.ref = worldStore.applyWorld(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.world(args.ref.id), + nextData: args + }).catch((err) => { + console.error('Failed to refresh world query after publish:', err); + }); return args; }); }, @@ -138,6 +162,12 @@ const worldReq = { params }; args.ref = worldStore.applyWorld(json); + patchAndRefetchActiveQuery({ + queryKey: queryKeys.world(args.ref.id), + nextData: args + }).catch((err) => { + console.error('Failed to refresh world query after unpublish:', err); + }); return args; }); }, diff --git a/src/app.js b/src/app.js index 13d5be11..7bc4c97a 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ import { createApp } from 'vue'; +import { VueQueryPlugin } from '@tanstack/vue-query'; import { i18n, @@ -8,6 +9,7 @@ import { initSentry } from './plugin'; import { initPiniaPlugins, pinia } from './stores'; +import { queryClient } from './query'; import App from './App.vue'; @@ -18,7 +20,7 @@ await initPiniaPlugins(); const app = createApp(App); -app.use(pinia).use(i18n); +app.use(pinia).use(i18n).use(VueQueryPlugin, { queryClient }); initComponents(app); initRouter(app); await initSentry(app); diff --git a/src/components/dialogs/AvatarDialog/AvatarDialog.vue b/src/components/dialogs/AvatarDialog/AvatarDialog.vue index 92f7e39f..d1c7e5c4 100644 --- a/src/components/dialogs/AvatarDialog/AvatarDialog.vue +++ b/src/components/dialogs/AvatarDialog/AvatarDialog.vue @@ -799,8 +799,7 @@ switch (command) { case 'Refresh': const avatarId = D.id; - D.id = ''; - showAvatarDialog(avatarId); + showAvatarDialog(avatarId, { forceRefresh: true }); break; case 'Share': copyAvatarUrl(D.id); @@ -1038,8 +1037,7 @@ toast.success(t('message.upload.success')); // force refresh cover image const avatarId = avatarDialog.value.id; - avatarDialog.value.id = ''; - showAvatarDialog(avatarId); + showAvatarDialog(avatarId, { forceRefresh: true }); } catch (error) { console.error('avatar image upload process failed:', error); toast.error(t('message.upload.error')); diff --git a/src/components/dialogs/GroupDialog/GroupDialog.vue b/src/components/dialogs/GroupDialog/GroupDialog.vue index d9d529f5..4bcb064f 100644 --- a/src/components/dialogs/GroupDialog/GroupDialog.vue +++ b/src/components/dialogs/GroupDialog/GroupDialog.vue @@ -1537,8 +1537,7 @@ break; case 'Refresh': const groupId = D.id; - D.id = ''; - showGroupDialog(groupId); + showGroupDialog(groupId, { forceRefresh: true }); break; case 'Leave Group': leaveGroupPrompt(D.id); @@ -1713,7 +1712,7 @@ } if (D.inGroup) { await groupRequest - .getGroupMember({ + .getCachedGroupMember({ groupId: D.id, userId: currentUser.value.id }) @@ -1744,7 +1743,7 @@ D.memberSearch = ''; isGroupMembersLoading.value = true; await groupRequest - .getGroupMembers(params) + .getCachedGroupMembers(params) .finally(() => { isGroupMembersLoading.value = false; }) @@ -1784,10 +1783,11 @@ updateGroupDialogData({ ...groupDialog.value, galleries: {} }); groupDialogGalleryCurrentName.value = '0'; isGroupGalleryLoading.value = true; - for (let i = 0; i < groupDialog.value.ref.galleries.length; i++) { - const gallery = groupDialog.value.ref.galleries[i]; - await getGroupGallery(groupDialog.value.id, gallery.id); - } + const groupId = groupDialog.value.id; + const tasks = (groupDialog.value.ref.galleries || []).map((gallery) => + getGroupGallery(groupId, gallery.id) + ); + await Promise.allSettled(tasks); isGroupGalleryLoading.value = false; } @@ -1801,7 +1801,7 @@ }; const count = 50; // 5000 max for (let i = 0; i < count; i++) { - const args = await groupRequest.getGroupGallery(params); + const args = await groupRequest.getCachedGroupGallery(params); if (args) { for (const json of args.json) { if (groupDialog.value.id === json.groupId) { @@ -1850,7 +1850,7 @@ async function setGroupMemberSortOrder(sortOrder) { const D = groupDialog.value; - if (D.memberSortOrder.value === sortOrder) { + if (D.memberSortOrder?.value === sortOrder?.value) { return; } D.memberSortOrder = sortOrder; diff --git a/src/components/dialogs/UserDialog/UserDialog.vue b/src/components/dialogs/UserDialog/UserDialog.vue index 2d3cde70..c115e4a2 100644 --- a/src/components/dialogs/UserDialog/UserDialog.vue +++ b/src/components/dialogs/UserDialog/UserDialog.vue @@ -1318,7 +1318,7 @@ userRequest, worldRequest } from '../../../api'; - import { processBulk, request } from '../../../service/request'; + import { processBulk } from '../../../service/request'; import { userDialogGroupSortingOptions, userDialogMutualFriendSortingOptions } from '../../../shared/constants'; import { userDialogWorldOrderOptions, userDialogWorldSortingOptions } from '../../../shared/constants/'; import { database } from '../../../service/database'; @@ -1429,6 +1429,8 @@ const userDialogLastFavoriteWorld = ref(''); const favoriteWorldsTab = ref('0'); + const userDialogWorldsRequestId = ref(0); + const userDialogFavoriteWorldsRequestId = ref(0); const sendInviteDialogVisible = ref(false); const sendInviteDialog = ref({ @@ -2388,6 +2390,7 @@ if (D.isWorldsLoading) { return; } + const requestId = ++userDialogWorldsRequestId.value; D.isWorldsLoading = true; const params = { n: 50, @@ -2402,30 +2405,40 @@ params.user = 'me'; params.releaseStatus = 'all'; } - const map = new Map(); - for (const ref of cachedWorlds.values()) { - if (ref.authorId === D.id && (ref.authorId === currentUser.value.id || ref.releaseStatus === 'public')) { - cachedWorlds.delete(ref.id); - } - } - processBulk({ - fn: worldRequest.getWorlds, - N: -1, - params, - handle: (args) => { - for (const json of args.json) { - const $ref = cachedWorlds.get(json.id); - if (typeof $ref !== 'undefined') { - map.set($ref.id, $ref); + const worlds = []; + const worldIds = new Set(); + (async () => { + try { + let offset = 0; + while (true) { + const args = await worldRequest.getCachedWorlds({ + ...params, + offset + }); + if (requestId !== userDialogWorldsRequestId.value || D.id !== params.userId) { + return; } + for (const world of args.json) { + if (!worldIds.has(world.id)) { + worldIds.add(world.id); + worlds.push(world); + } + } + if (args.json.length < params.n) { + break; + } + offset += params.n; } - }, - done: () => { - if (D.id === params.userId) { - setUserDialogWorlds(D.id); + if (requestId === userDialogWorldsRequestId.value && D.id === params.userId) { + userDialog.value.worlds = worlds; + } + } finally { + if (requestId === userDialogWorldsRequestId.value) { + D.isWorldsLoading = false; } - D.isWorldsLoading = false; } + })().catch((err) => { + console.error('refreshUserDialogWorlds failed', err); }); } @@ -2434,25 +2447,28 @@ * @param userId */ async function getUserFavoriteWorlds(userId) { + const requestId = ++userDialogFavoriteWorldsRequestId.value; userDialog.value.isFavoriteWorldsLoading = true; favoriteWorldsTab.value = '0'; userDialog.value.userFavoriteWorlds = []; const worldLists = []; - let params = { + const groupArgs = await favoriteRequest.getCachedFavoriteGroups({ ownerId: userId, n: 100, offset: 0 - }; - const json = await request('favorite/groups', { - method: 'GET', - params }); - for (let i = 0; i < json.length; ++i) { - const list = json[i]; - if (list.type !== 'world') { - continue; + if (requestId !== userDialogFavoriteWorldsRequestId.value || userDialog.value.id !== userId) { + if (requestId === userDialogFavoriteWorldsRequestId.value) { + userDialog.value.isFavoriteWorldsLoading = false; } - params = { + return; + } + const worldGroups = groupArgs.json.filter((list) => list.type === 'world'); + const tasks = worldGroups.map(async (list) => { + if (list.type !== 'world') { + return null; + } + const params = { ownerId: userId, n: 100, offset: 0, @@ -2460,15 +2476,26 @@ tag: list.name }; try { - const args = await favoriteRequest.getFavoriteWorlds(params); + const args = await favoriteRequest.getCachedFavoriteWorlds(params); handleFavoriteWorldList(args); - worldLists.push([list.displayName, list.visibility, args.json]); + return [list.displayName, list.visibility, args.json]; } catch (err) { console.error('getUserFavoriteWorlds', err); + return null; + } + }); + const results = await Promise.all(tasks); + for (const result of results) { + if (result) { + worldLists.push(result); } } - userDialog.value.userFavoriteWorlds = worldLists; - userDialog.value.isFavoriteWorldsLoading = false; + if (requestId === userDialogFavoriteWorldsRequestId.value) { + if (userDialog.value.id === userId) { + userDialog.value.userFavoriteWorlds = worldLists; + } + userDialog.value.isFavoriteWorldsLoading = false; + } } /** diff --git a/src/components/dialogs/WorldDialog/WorldDialog.vue b/src/components/dialogs/WorldDialog/WorldDialog.vue index 8ef89dbe..54a7e84d 100644 --- a/src/components/dialogs/WorldDialog/WorldDialog.vue +++ b/src/components/dialogs/WorldDialog/WorldDialog.vue @@ -1212,8 +1212,7 @@ break; case 'Refresh': const { tag, shortName } = worldDialog.value.$location; - D.id = ''; - showWorldDialog(tag, shortName); + showWorldDialog(tag, shortName, { forceRefresh: true }); break; case 'New Instance': showNewInstanceDialog(D.$location.tag); diff --git a/src/query/__tests__/entityCache.test.js b/src/query/__tests__/entityCache.test.js new file mode 100644 index 00000000..c7fe9829 --- /dev/null +++ b/src/query/__tests__/entityCache.test.js @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + _entityCacheInternals, + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + patchQueryDataWithRecency +} from '../entityCache'; +import { queryClient } from '../client'; + +describe('entity query cache helpers', () => { + beforeEach(() => { + queryClient.clear(); + vi.restoreAllMocks(); + }); + + test('reports cache hit for fresh data', async () => { + const queryKey = ['user', 'usr_1']; + let callCount = 0; + + const policy = { + staleTime: 20000, + gcTime: 90000, + retry: 1, + refetchOnWindowFocus: false + }; + + const queryFn = vi.fn(async () => { + callCount++; + return { + json: { id: 'usr_1', updated_at: '2026-01-01T00:00:00.000Z' }, + params: { userId: 'usr_1' }, + ref: { id: 'usr_1', updated_at: '2026-01-01T00:00:00.000Z' } + }; + }); + + const first = await fetchWithEntityPolicy({ queryKey, policy, queryFn }); + const second = await fetchWithEntityPolicy({ queryKey, policy, queryFn }); + + expect(first.cache).toBe(false); + expect(second.cache).toBe(true); + expect(callCount).toBe(1); + }); + + test('always refetches when staleTime is zero (instance strategy)', async () => { + const queryKey = ['instance', 'wrld_1', '12345']; + let callCount = 0; + + const policy = { + staleTime: 0, + gcTime: 10000, + retry: 0, + refetchOnWindowFocus: false + }; + + const queryFn = vi.fn(async () => { + callCount++; + return { + json: { + id: 'wrld_1:12345', + $fetchedAt: new Date().toJSON() + }, + params: { worldId: 'wrld_1', instanceId: '12345' }, + ref: { + id: 'wrld_1:12345', + $fetchedAt: new Date().toJSON() + } + }; + }); + + await fetchWithEntityPolicy({ queryKey, policy, queryFn }); + await fetchWithEntityPolicy({ queryKey, policy, queryFn }); + + expect(callCount).toBe(2); + }); + + test('does not overwrite newer data with older payload', () => { + const queryKey = ['world', 'wrld_1']; + + patchQueryDataWithRecency({ + queryKey, + nextData: { + ref: { id: 'wrld_1', updated_at: '2026-01-01T00:00:00.000Z' } + } + }); + + patchQueryDataWithRecency({ + queryKey, + nextData: { + ref: { id: 'wrld_1', updated_at: '2025-01-01T00:00:00.000Z' } + } + }); + + const cached = queryClient.getQueryData(queryKey); + expect(cached.ref.updated_at).toBe('2026-01-01T00:00:00.000Z'); + }); + + test('patch and refetch invalidates only active queries for that key', async () => { + const invalidateSpy = vi + .spyOn(queryClient, 'invalidateQueries') + .mockResolvedValue(); + + const queryKey = ['avatar', 'avtr_1']; + await patchAndRefetchActiveQuery({ + queryKey, + nextData: { + ref: { id: 'avtr_1', updated_at: '2026-01-01T00:00:00.000Z' } + } + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey, + exact: true, + refetchType: 'active' + }); + }); + + test('internal recency guard prefers same-or-newer timestamps', () => { + const newer = { + ref: { id: 'usr_1', updated_at: '2026-02-01T00:00:00.000Z' } + }; + const older = { + ref: { id: 'usr_1', updated_at: '2026-01-01T00:00:00.000Z' } + }; + + expect(_entityCacheInternals.shouldReplaceCurrent(older, newer)).toBe( + true + ); + expect(_entityCacheInternals.shouldReplaceCurrent(newer, older)).toBe( + false + ); + }); +}); diff --git a/src/query/__tests__/keys.test.js b/src/query/__tests__/keys.test.js new file mode 100644 index 00000000..d696106e --- /dev/null +++ b/src/query/__tests__/keys.test.js @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; + +import { queryKeys } from '../keys'; + +describe('query key shapes', () => { + test('favorite world keys include owner and tag dimensions', () => { + const a = queryKeys.favoriteWorlds({ + n: 100, + offset: 0, + ownerId: 'usr_1', + userId: 'usr_1', + tag: 'worlds1' + }); + const b = queryKeys.favoriteWorlds({ + n: 100, + offset: 0, + ownerId: 'usr_2', + userId: 'usr_2', + tag: 'worlds1' + }); + + expect(a).not.toEqual(b); + }); + + test('world list keys include query option discriminator', () => { + const base = { + userId: 'usr_me', + n: 50, + offset: 0, + sort: 'updated', + order: 'descending', + user: 'me', + releaseStatus: 'all' + }; + + const defaultKey = queryKeys.worldsByUser(base); + const featuredKey = queryKeys.worldsByUser({ + ...base, + option: 'featured' + }); + + expect(defaultKey).not.toEqual(featuredKey); + }); + + test('group member list keys include sort and role dimensions', () => { + const everyone = queryKeys.groupMembers({ + groupId: 'grp_1', + n: 100, + offset: 0, + sort: 'joinedAt:desc', + roleId: '' + }); + const roleScoped = queryKeys.groupMembers({ + groupId: 'grp_1', + n: 100, + offset: 0, + sort: 'joinedAt:desc', + roleId: 'grol_1' + }); + + expect(everyone).not.toEqual(roleScoped); + }); +}); diff --git a/src/query/__tests__/policies.test.js b/src/query/__tests__/policies.test.js new file mode 100644 index 00000000..5a69f7f0 --- /dev/null +++ b/src/query/__tests__/policies.test.js @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; + +import { + entityQueryPolicies, + getEntityQueryPolicy, + toQueryOptions +} from '../policies'; + +describe('query policy configuration', () => { + test('matches the finalized cache strategy', () => { + expect(entityQueryPolicies.user).toMatchObject({ + staleTime: 20000, + gcTime: 90000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.avatar).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.world).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.group).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.groupCollection).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.worldCollection).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.instance).toMatchObject({ + staleTime: 0, + gcTime: 10000, + retry: 0, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.friendList).toMatchObject({ + staleTime: 20000, + gcTime: 90000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.favoriteCollection).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.galleryCollection).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.inventoryCollection).toMatchObject({ + staleTime: 20000, + gcTime: 120000, + retry: 1, + refetchOnWindowFocus: false + }); + + expect(entityQueryPolicies.fileObject).toMatchObject({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + }); + + test('exposes entity policy lookup', () => { + expect(getEntityQueryPolicy('user')).toBe(entityQueryPolicies.user); + expect(getEntityQueryPolicy('avatar')).toBe(entityQueryPolicies.avatar); + expect(getEntityQueryPolicy('world')).toBe(entityQueryPolicies.world); + expect(getEntityQueryPolicy('group')).toBe(entityQueryPolicies.group); + expect(getEntityQueryPolicy('groupCollection')).toBe( + entityQueryPolicies.groupCollection + ); + expect(getEntityQueryPolicy('worldCollection')).toBe( + entityQueryPolicies.worldCollection + ); + expect(getEntityQueryPolicy('instance')).toBe( + entityQueryPolicies.instance + ); + expect(getEntityQueryPolicy('friendList')).toBe( + entityQueryPolicies.friendList + ); + expect(getEntityQueryPolicy('favoriteCollection')).toBe( + entityQueryPolicies.favoriteCollection + ); + expect(getEntityQueryPolicy('galleryCollection')).toBe( + entityQueryPolicies.galleryCollection + ); + expect(getEntityQueryPolicy('inventoryCollection')).toBe( + entityQueryPolicies.inventoryCollection + ); + expect(getEntityQueryPolicy('fileObject')).toBe( + entityQueryPolicies.fileObject + ); + }); + + test('normalizes policy values to query options', () => { + const options = toQueryOptions(entityQueryPolicies.group); + + expect(options).toEqual({ + staleTime: 60000, + gcTime: 300000, + retry: 1, + refetchOnWindowFocus: false + }); + }); +}); diff --git a/src/query/client.js b/src/query/client.js new file mode 100644 index 00000000..b7ffe180 --- /dev/null +++ b/src/query/client.js @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/vue-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + refetchOnReconnect: true + } + } +}); diff --git a/src/query/entityCache.js b/src/query/entityCache.js new file mode 100644 index 00000000..96e842a3 --- /dev/null +++ b/src/query/entityCache.js @@ -0,0 +1,230 @@ +import { queryClient } from './client'; +import { queryKeys } from './keys'; +import { toQueryOptions } from './policies'; + +const RECENCY_FIELDS = [ + 'updated_at', + 'updatedAt', + 'last_activity', + 'last_login', + 'memberCountSyncedAt', + '$location_at', + '$lastFetch', + '$fetchedAt', + 'created_at', + 'createdAt' +]; + +function getComparableEntity(data) { + if (!data || typeof data !== 'object') { + return null; + } + if (data.ref && typeof data.ref === 'object') { + return data.ref; + } + if (data.json && typeof data.json === 'object' && !Array.isArray(data.json)) { + return data.json; + } + return data; +} + +function parseTimestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value !== '') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return null; +} + +function getRecencyTimestamp(data) { + const comparable = getComparableEntity(data); + if (!comparable) { + return null; + } + + for (const field of RECENCY_FIELDS) { + const ts = parseTimestamp(comparable[field]); + if (ts !== null) { + return ts; + } + } + + return null; +} + +function shouldReplaceCurrent(currentData, nextData) { + if (typeof currentData === 'undefined') { + return true; + } + + const currentTs = getRecencyTimestamp(currentData); + const nextTs = getRecencyTimestamp(nextData); + + if (currentTs !== null && nextTs !== null) { + return nextTs >= currentTs; + } + + if (currentTs !== null && nextTs === null) { + return false; + } + + return true; +} + +/** + * @param {{queryKey: unknown[], nextData: any}} options + */ +export function patchQueryDataWithRecency({ queryKey, nextData }) { + queryClient.setQueryData(queryKey, (currentData) => { + if (!shouldReplaceCurrent(currentData, nextData)) { + return currentData; + } + return nextData; + }); +} + +/** + * @param {{queryKey: unknown[], policy: {staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}, queryFn: () => Promise}} options + * @returns {Promise<{data: any, cache: boolean}>} + */ +export async function fetchWithEntityPolicy({ queryKey, policy, queryFn }) { + const queryState = queryClient.getQueryState(queryKey); + const isFresh = + Boolean(queryState?.dataUpdatedAt) && + policy.staleTime > 0 && + Date.now() - queryState.dataUpdatedAt < policy.staleTime; + + const data = await queryClient.fetchQuery({ + queryKey, + queryFn, + ...toQueryOptions(policy) + }); + + return { + data, + cache: isFresh + }; +} + +/** + * @param {unknown[]} queryKey + * @returns {Promise} + */ +export async function refetchActiveEntityQuery(queryKey) { + await queryClient.invalidateQueries({ + queryKey, + exact: true, + refetchType: 'active' + }); +} + +/** + * @param {{queryKey: unknown[], nextData: any}} options + * @returns {Promise} + */ +export async function patchAndRefetchActiveQuery({ queryKey, nextData }) { + patchQueryDataWithRecency({ queryKey, nextData }); + await refetchActiveEntityQuery(queryKey); +} + +/** + * @param {object} ref + */ +export function patchUserFromEvent(ref) { + if (!ref?.id) return; + patchQueryDataWithRecency({ + queryKey: queryKeys.user(ref.id), + nextData: { + cache: false, + json: ref, + params: { userId: ref.id }, + ref + } + }); +} + +/** + * @param {object} ref + */ +export function patchAvatarFromEvent(ref) { + if (!ref?.id) return; + patchQueryDataWithRecency({ + queryKey: queryKeys.avatar(ref.id), + nextData: { + cache: false, + json: ref, + params: { avatarId: ref.id }, + ref + } + }); +} + +/** + * @param {object} ref + */ +export function patchWorldFromEvent(ref) { + if (!ref?.id) return; + patchQueryDataWithRecency({ + queryKey: queryKeys.world(ref.id), + nextData: { + cache: false, + json: ref, + params: { worldId: ref.id }, + ref + } + }); +} + +/** + * @param {object} ref + */ +export function patchGroupFromEvent(ref) { + if (!ref?.id) return; + + const nextData = { + cache: false, + json: ref, + params: { groupId: ref.id }, + ref + }; + + patchQueryDataWithRecency({ + queryKey: queryKeys.group(ref.id, false), + nextData + }); + + patchQueryDataWithRecency({ + queryKey: queryKeys.group(ref.id, true), + nextData + }); +} + +/** + * @param {object} ref + */ +export function patchInstanceFromEvent(ref) { + if (!ref?.id) return; + + const [worldId, instanceId] = String(ref.id).split(':'); + if (!worldId || !instanceId) return; + + patchQueryDataWithRecency({ + queryKey: queryKeys.instance(worldId, instanceId), + nextData: { + cache: false, + json: ref, + params: { worldId, instanceId }, + ref + } + }); +} + +export const _entityCacheInternals = { + getRecencyTimestamp, + shouldReplaceCurrent +}; diff --git a/src/query/index.js b/src/query/index.js new file mode 100644 index 00000000..9412dc99 --- /dev/null +++ b/src/query/index.js @@ -0,0 +1,14 @@ +export { queryClient } from './client'; +export { queryKeys } from './keys'; +export { entityQueryPolicies, getEntityQueryPolicy, toQueryOptions } from './policies'; +export { + fetchWithEntityPolicy, + patchAndRefetchActiveQuery, + patchQueryDataWithRecency, + patchUserFromEvent, + patchAvatarFromEvent, + patchWorldFromEvent, + patchGroupFromEvent, + patchInstanceFromEvent, + refetchActiveEntityQuery +} from './entityCache'; diff --git a/src/query/keys.js b/src/query/keys.js new file mode 100644 index 00000000..1bcb1adc --- /dev/null +++ b/src/query/keys.js @@ -0,0 +1,149 @@ +export const queryKeys = Object.freeze({ + user: (userId) => ['user', userId], + avatar: (avatarId) => ['avatar', avatarId], + world: (worldId) => ['world', worldId], + group: (groupId, includeRoles = false) => ['group', groupId, Boolean(includeRoles)], + groupPosts: ({ groupId, n = 100, offset = 0 } = {}) => [ + 'group', + groupId, + 'posts', + { + n: Number(n), + offset: Number(offset) + } + ], + groupMember: ({ groupId, userId } = {}) => ['group', groupId, 'member', userId], + groupMembers: ({ groupId, n = 100, offset = 0, sort = '', roleId = '' } = {}) => [ + 'group', + groupId, + 'members', + { + n: Number(n), + offset: Number(offset), + sort: String(sort || ''), + roleId: String(roleId || '') + } + ], + groupGallery: ({ groupId, galleryId, n = 100, offset = 0 } = {}) => [ + 'group', + groupId, + 'gallery', + galleryId, + { + n: Number(n), + offset: Number(offset) + } + ], + groupCalendar: (groupId) => ['group', groupId, 'calendar'], + groupCalendarEvent: ({ groupId, eventId } = {}) => [ + 'group', + groupId, + 'calendarEvent', + eventId + ], + instance: (worldId, instanceId) => ['instance', worldId, instanceId], + worldsByUser: ({ + userId, + n = 50, + offset = 0, + sort = '', + order = '', + user = '', + releaseStatus = '', + option = '' + } = {}) => [ + 'worlds', + 'user', + userId, + { + n: Number(n), + offset: Number(offset), + sort: String(sort || ''), + order: String(order || ''), + user: String(user || ''), + releaseStatus: String(releaseStatus || ''), + option: String(option || '') + } + ], + friends: ({ offline = false, n = 50, offset = 0 } = {}) => [ + 'friends', + { + offline: Boolean(offline), + n: Number(n), + offset: Number(offset) + } + ], + favoriteLimits: () => ['favorite', 'limits'], + favorites: ({ n = 300, offset = 0 } = {}) => [ + 'favorite', + 'items', + { + n: Number(n), + offset: Number(offset) + } + ], + favoriteGroups: ({ n = 50, offset = 0, type = '' } = {}) => [ + 'favorite', + 'groups', + { + n: Number(n), + offset: Number(offset), + type: String(type || '') + } + ], + favoriteWorlds: ({ n = 300, offset = 0, ownerId = '', userId = '', tag = '' } = {}) => [ + 'favorite', + 'worlds', + { + n: Number(n), + offset: Number(offset), + ownerId: String(ownerId || ''), + userId: String(userId || ''), + tag: String(tag || '') + } + ], + favoriteAvatars: ({ n = 300, offset = 0, tag = '', ownerId = '', userId = '' } = {}) => [ + 'favorite', + 'avatars', + { + n: Number(n), + offset: Number(offset), + tag: String(tag || ''), + ownerId: String(ownerId || ''), + userId: String(userId || '') + } + ], + galleryFiles: ({ tag = '', n = 100 } = {}) => [ + 'gallery', + 'files', + { + tag: String(tag || ''), + n: Number(n) + } + ], + prints: ({ n = 100 } = {}) => [ + 'gallery', + 'prints', + { + n: Number(n) + } + ], + print: (printId) => ['gallery', 'print', printId], + inventoryItems: ({ n = 100, offset = 0, order = 'newest', types = '' } = {}) => [ + 'inventory', + 'items', + { + n: Number(n), + offset: Number(offset), + order: String(order || 'newest'), + types: String(types || '') + } + ], + userInventoryItem: ({ inventoryId, userId }) => [ + 'inventory', + 'item', + userId, + inventoryId + ], + file: (fileId) => ['file', fileId] +}); diff --git a/src/query/policies.js b/src/query/policies.js new file mode 100644 index 00000000..e2ecc6d9 --- /dev/null +++ b/src/query/policies.js @@ -0,0 +1,97 @@ +const SECOND = 1000; + +export const entityQueryPolicies = Object.freeze({ + user: Object.freeze({ + staleTime: 20 * SECOND, + gcTime: 90 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + avatar: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + world: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + group: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + groupCollection: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + worldCollection: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + instance: Object.freeze({ + staleTime: 0, + gcTime: 10 * SECOND, + retry: 0, + refetchOnWindowFocus: false + }), + friendList: Object.freeze({ + staleTime: 20 * SECOND, + gcTime: 90 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + favoriteCollection: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + galleryCollection: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + inventoryCollection: Object.freeze({ + staleTime: 20 * SECOND, + gcTime: 120 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }), + fileObject: Object.freeze({ + staleTime: 60 * SECOND, + gcTime: 300 * SECOND, + retry: 1, + refetchOnWindowFocus: false + }) +}); + +/** + * @param {'user'|'avatar'|'world'|'group'|'groupCollection'|'worldCollection'|'instance'|'friendList'|'favoriteCollection'|'galleryCollection'|'inventoryCollection'|'fileObject'} entity + * @returns {{staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}} + */ +export function getEntityQueryPolicy(entity) { + return entityQueryPolicies[entity]; +} + +/** + * @param {{staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}} policy + * @returns {{staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}} + */ +export function toQueryOptions(policy) { + return { + staleTime: policy.staleTime, + gcTime: policy.gcTime, + retry: policy.retry, + refetchOnWindowFocus: policy.refetchOnWindowFocus + }; +} diff --git a/src/query/useEntityQueries.js b/src/query/useEntityQueries.js new file mode 100644 index 00000000..df9188aa --- /dev/null +++ b/src/query/useEntityQueries.js @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/vue-query'; + +import { avatarRequest, groupRequest, instanceRequest, userRequest, worldRequest } from '../api'; +import { queryKeys } from './keys'; +import { entityQueryPolicies, toQueryOptions } from './policies'; + +export function useUserQuery(userId, options = {}) { + return useQuery({ + ...options, + queryKey: queryKeys.user(userId), + queryFn: () => userRequest.getUser({ userId }), + enabled: Boolean(userId), + ...toQueryOptions(entityQueryPolicies.user) + }); +} + +export function useAvatarQuery(avatarId, options = {}) { + return useQuery({ + ...options, + queryKey: queryKeys.avatar(avatarId), + queryFn: () => avatarRequest.getAvatar({ avatarId }), + enabled: Boolean(avatarId), + ...toQueryOptions(entityQueryPolicies.avatar) + }); +} + +export function useWorldQuery(worldId, options = {}) { + return useQuery({ + ...options, + queryKey: queryKeys.world(worldId), + queryFn: () => worldRequest.getWorld({ worldId }), + enabled: Boolean(worldId), + ...toQueryOptions(entityQueryPolicies.world) + }); +} + +export function useGroupQuery(groupId, includeRoles = false, options = {}) { + return useQuery({ + ...options, + queryKey: queryKeys.group(groupId, includeRoles), + queryFn: () => groupRequest.getGroup({ groupId, includeRoles }), + enabled: Boolean(groupId), + ...toQueryOptions(entityQueryPolicies.group) + }); +} + +export function useInstanceQuery(worldId, instanceId, options = {}) { + return useQuery({ + ...options, + queryKey: queryKeys.instance(worldId, instanceId), + queryFn: () => instanceRequest.getInstance({ worldId, instanceId }), + enabled: Boolean(worldId && instanceId), + ...toQueryOptions(entityQueryPolicies.instance) + }); +} diff --git a/src/stores/avatar.js b/src/stores/avatar.js index c6f6c880..40b531b6 100644 --- a/src/stores/avatar.js +++ b/src/stores/avatar.js @@ -13,6 +13,7 @@ import { storeAvatarImage } from '../shared/utils'; import { avatarRequest, miscRequest } from '../api'; +import { patchAvatarFromEvent } from '../query'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; import { processBulk } from '../service/request'; @@ -170,6 +171,7 @@ export const useAvatarStore = defineStore('Avatar', () => { // update db cache database.addAvatarToCache(avatarRef); } + patchAvatarFromEvent(ref); return ref; } @@ -178,14 +180,15 @@ export const useAvatarStore = defineStore('Avatar', () => { * @param {string} avatarId * @returns */ - function showAvatarDialog(avatarId) { + function showAvatarDialog(avatarId, options = {}) { const D = avatarDialog.value; + const forceRefresh = Boolean(options?.forceRefresh); const isMainDialogOpen = uiStore.openDialog({ type: 'avatar', id: avatarId }); D.visible = true; - if (isMainDialogOpen && D.id === avatarId) { + if (isMainDialogOpen && D.id === avatarId && !forceRefresh) { uiStore.setDialogCrumbLabel('avatar', D.id, D.ref?.name || D.id); nextTick(() => (D.loading = false)); return; @@ -217,8 +220,10 @@ export const useAvatarStore = defineStore('Avatar', () => { uiStore.setDialogCrumbLabel('avatar', D.id, D.ref?.name || D.id); nextTick(() => (D.loading = false)); } - avatarRequest - .getAvatar({ avatarId }) + const loadAvatarRequest = forceRefresh + ? avatarRequest.getAvatar({ avatarId }) + : avatarRequest.getCachedAvatar({ avatarId }); + loadAvatarRequest .then((args) => { const ref = applyAvatar(args.json); D.ref = ref; diff --git a/src/stores/favorite.js b/src/stores/favorite.js index 52231fbb..38443100 100644 --- a/src/stores/favorite.js +++ b/src/stores/favorite.js @@ -542,7 +542,7 @@ export const useFavoriteStore = defineStore('Favorite', () => { } isFavoriteGroupLoading.value = true; processBulk({ - fn: favoriteRequest.getFavoriteGroups, + fn: favoriteRequest.getCachedFavoriteGroups, N: -1, params: { n: 50, @@ -707,7 +707,7 @@ export const useFavoriteStore = defineStore('Favorite', () => { } isFavoriteLoading.value = true; try { - const args = await favoriteRequest.getFavoriteLimits(); + const args = await favoriteRequest.getCachedFavoriteLimits(); favoriteLimits.value = { ...favoriteLimits.value, ...args.json @@ -717,7 +717,7 @@ export const useFavoriteStore = defineStore('Favorite', () => { } let newFavoriteSortOrder = []; processBulk({ - fn: favoriteRequest.getFavorites, + fn: favoriteRequest.getCachedFavorites, N: -1, params: { n: 300, @@ -839,7 +839,7 @@ export const useFavoriteStore = defineStore('Favorite', () => { offset: 0, tag }; - const args = await favoriteRequest.getFavoriteAvatars(params); + const args = await favoriteRequest.getCachedFavoriteAvatars(params); handleFavoriteAvatarList(args); } @@ -848,8 +848,8 @@ export const useFavoriteStore = defineStore('Favorite', () => { */ function refreshFavoriteItems() { const types = { - world: [0, favoriteRequest.getFavoriteWorlds], - avatar: [0, favoriteRequest.getFavoriteAvatars] + world: [0, favoriteRequest.getCachedFavoriteWorlds], + avatar: [0, favoriteRequest.getCachedFavoriteAvatars] }; const tags = []; for (const ref of cachedFavorites.values()) { diff --git a/src/stores/friend.js b/src/stores/friend.js index 9dba6577..8ef3b45b 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -777,7 +777,7 @@ export const useFriendStore = defineStore('Friend', () => { async function fetchPage(offset) { const result = await executeWithBackoff( async () => { - const { json } = await friendRequest.getFriends({ + const { json } = await friendRequest.getCachedFriends({ ...args, n: PAGE_SIZE, offset diff --git a/src/stores/gallery.js b/src/stores/gallery.js index e8195ac7..e8319f9f 100644 --- a/src/stores/gallery.js +++ b/src/stores/gallery.js @@ -149,7 +149,7 @@ export const useGalleryStore = defineStore('Gallery', () => { tag: 'gallery' }; vrcPlusIconRequest - .getFileList(params) + .getCachedFileList(params) .then((args) => handleFilesList(args)) .catch((error) => { console.error('Error fetching gallery files:', error); @@ -166,7 +166,7 @@ export const useGalleryStore = defineStore('Gallery', () => { tag: 'icon' }; vrcPlusIconRequest - .getFileList(params) + .getCachedFileList(params) .then((args) => handleFilesList(args)) .catch((error) => { console.error('Error fetching VRC Plus icons:', error); @@ -208,7 +208,7 @@ export const useGalleryStore = defineStore('Gallery', () => { tag: 'sticker' }; vrcPlusIconRequest - .getFileList(params) + .getCachedFileList(params) .then((args) => handleFilesList(args)) .catch((error) => { console.error('Error fetching stickers:', error); @@ -232,7 +232,7 @@ export const useGalleryStore = defineStore('Gallery', () => { if (instanceStickersCache.value.length > 100) { instanceStickersCache.value.shift(); } - const args = await inventoryRequest.getUserInventoryItem({ + const args = await inventoryRequest.getCachedUserInventoryItem({ inventoryId, userId }); @@ -269,7 +269,7 @@ export const useGalleryStore = defineStore('Gallery', () => { n: 100 }; try { - const args = await vrcPlusImageRequest.getPrints(params); + const args = await vrcPlusImageRequest.getCachedPrints(params); args.json.sort((a, b) => { return ( new Date(b.timestamp).getTime() - @@ -306,7 +306,7 @@ export const useGalleryStore = defineStore('Gallery', () => { } async function trySavePrintToFile(printId) { - const args = await vrcPlusImageRequest.getPrint({ printId }); + const args = await vrcPlusImageRequest.getCachedPrint({ printId }); const imageUrl = args.json?.files?.image; if (!imageUrl) { console.error('Print image URL is missing', args); @@ -357,7 +357,7 @@ export const useGalleryStore = defineStore('Gallery', () => { tag: 'emoji' }; vrcPlusIconRequest - .getFileList(params) + .getCachedFileList(params) .then((args) => handleFilesList(args)) .catch((error) => { console.error('Error fetching emojis:', error); @@ -379,7 +379,9 @@ export const useGalleryStore = defineStore('Gallery', () => { try { for (let i = 0; i < 100; i++) { params.offset = i * params.n; - const args = await inventoryRequest.getInventoryItems(params); + const args = await inventoryRequest.getCachedInventoryItems( + params + ); for (const item of args.json.data) { advancedSettingsStore.currentUserInventory.set( item.id, @@ -476,7 +478,7 @@ export const useGalleryStore = defineStore('Gallery', () => { } async function trySaveEmojiToFile(inventoryId, userId) { - const args = await inventoryRequest.getUserInventoryItem({ + const args = await inventoryRequest.getCachedUserInventoryItem({ inventoryId, userId }); @@ -548,7 +550,7 @@ export const useGalleryStore = defineStore('Gallery', () => { return; } miscReq - .getFile({ fileId }) + .getCachedFile({ fileId }) .then((args) => { cachedEmoji.set(fileId, args.json); resolve(args.json); diff --git a/src/stores/group.js b/src/stores/group.js index 27858f1b..13ca1e8c 100644 --- a/src/stores/group.js +++ b/src/stores/group.js @@ -9,6 +9,7 @@ import { userRequest, worldRequest } from '../api'; +import { patchGroupFromEvent } from '../query'; import { convertFileUrlToImageUrl, hasGroupPermission, @@ -127,17 +128,18 @@ export const useGroupStore = defineStore('Group', () => { { flush: 'sync' } ); - function showGroupDialog(groupId) { + function showGroupDialog(groupId, options = {}) { if (!groupId) { return; } + const forceRefresh = Boolean(options?.forceRefresh); const isMainDialogOpen = uiStore.openDialog({ type: 'group', id: groupId }); const D = groupDialog.value; D.visible = true; - if (isMainDialogOpen && D.id === groupId) { + if (isMainDialogOpen && D.id === groupId && !forceRefresh) { uiStore.setDialogCrumbLabel('group', D.id, D.ref?.name || D.id); instanceStore.applyGroupDialogInstances(); D.loading = false; @@ -159,10 +161,15 @@ export const useGroupStore = defineStore('Group', () => { D.members = []; D.memberFilter = groupDialogFilterOptions.everyone; D.calendar = []; - groupRequest - .getCachedGroup({ - groupId - }) + const loadGroupRequest = forceRefresh + ? groupRequest.getGroup({ + groupId, + includeRoles: false + }) + : groupRequest.getCachedGroup({ + groupId + }); + loadGroupRequest .catch((err) => { D.loading = false; D.id = null; @@ -172,29 +179,27 @@ export const useGroupStore = defineStore('Group', () => { throw err; }) .then((args) => { - if (groupId === args.ref.id) { - D.ref = args.ref; + const ref = args.ref || applyGroup(args.json); + if (groupId === ref.id) { + D.ref = ref; uiStore.setDialogCrumbLabel( 'group', D.id, D.ref?.name || D.id ); - D.inGroup = args.ref.membershipStatus === 'member'; - D.ownerDisplayName = args.ref.ownerId; + D.inGroup = ref.membershipStatus === 'member'; + D.ownerDisplayName = ref.ownerId; D.visible = true; D.loading = false; - if (args.cache) { - groupRequest.getGroup(args.params); - } userRequest .getCachedUser({ - userId: args.ref.ownerId + userId: ref.ownerId }) .then((args1) => { D.ownerDisplayName = args1.ref.displayName; }); database.getLastGroupVisit(D.ref.name).then((r) => { - if (D.id === args.ref.id) { + if (D.id === ref.id) { D.lastVisit = r.created_at; } }); @@ -400,33 +405,37 @@ export const useGroupStore = defineStore('Group', () => { */ async function getAllGroupPosts(params) { const n = 100; - let posts = []; + const posts = []; let offset = 0; - let total = 0; - const args = await groupRequest.getGroupPosts({ - groupId: params.groupId, - n, - offset - }); + let total = Infinity; + let pages = 0; do { - posts = posts.concat(args.json.posts); - total = args.json.total; + const args = await groupRequest.getCachedGroupPosts({ + groupId: params.groupId, + n, + offset + }); + const pagePosts = args.json?.posts ?? []; + total = Number(args.json?.total ?? pagePosts.length); + posts.push(...pagePosts); offset += n; - } while (offset < total); + pages += 1; + if (pagePosts.length === 0) { + break; + } + } while (offset < total && pages < 50); const returnArgs = { posts, params }; const D = groupDialog.value; - if (D.id === args.params.groupId) { - for (const post of args.json.posts) { + if (D.id === params.groupId) { + for (const post of posts) { post.title = replaceBioSymbols(post.title); post.text = replaceBioSymbols(post.text); } - if (args.json.posts.length > 0) { - D.announcement = args.json.posts[0]; - } - D.posts = args.json.posts; + D.announcement = posts[0] ?? {}; + D.posts = posts; updateGroupPostSearch(); } @@ -437,7 +446,7 @@ export const useGroupStore = defineStore('Group', () => { const D = groupDialog.value; D.isGetGroupDialogGroupLoading = false; return groupRequest - .getGroup({ groupId, includeRoles: true }) + .getCachedGroup({ groupId, includeRoles: true }) .catch((err) => { throw err; }) @@ -447,6 +456,7 @@ export const useGroupStore = defineStore('Group', () => { D.loading = false; D.ref = ref; D.inGroup = ref.membershipStatus === 'member'; + D.memberRoles = []; for (const role of ref.roles) { if ( D.ref && @@ -487,14 +497,14 @@ export const useGroupStore = defineStore('Group', () => { }); } }); - groupRequest.getGroupCalendar(groupId).then((args) => { + groupRequest.getCachedGroupCalendar(groupId).then((args) => { if (groupDialog.value.id === args.params.groupId) { D.calendar = args.json.results; for (const event of D.calendar) { applyGroupEvent(event); // fetch again for isFollowing groupRequest - .getGroupCalendarEvent({ + .getCachedGroupCalendarEvent({ groupId, eventId: event.id }) @@ -782,6 +792,7 @@ export const useGroupStore = defineStore('Group', () => { D.inGroup = ref.membershipStatus === 'member'; D.ref = ref; } + patchGroupFromEvent(ref); return ref; } diff --git a/src/stores/instance.js b/src/stores/instance.js index 2ee6177a..c1335001 100644 --- a/src/stores/instance.js +++ b/src/stores/instance.js @@ -25,6 +25,7 @@ import { userRequest, worldRequest } from '../api'; +import { patchInstanceFromEvent } from '../query'; import { accessTypeLocaleKeyMap, instanceContentSettings @@ -573,6 +574,7 @@ export const useInstanceStore = defineStore('Instance', () => { } } lastInstanceApplied.value = ref.id; + patchInstanceFromEvent(ref); return ref; } diff --git a/src/stores/user.js b/src/stores/user.js index a31afbb2..8e98453d 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -29,6 +29,7 @@ import { instanceRequest, userRequest } from '../api'; +import { patchUserFromEvent } from '../query'; import { processBulk, request } from '../service/request'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; @@ -764,6 +765,7 @@ export const useUserStore = defineStore('User', () => { } } } + patchUserFromEvent(ref); return ref; } @@ -920,9 +922,6 @@ export const useUserStore = defineStore('User', () => { if (locationStore.lastLocation.playerList.has(D.ref.id)) { inCurrentWorld = true; } - if (args.cache) { - userRequest.getUser(args.params); - } if (userId !== currentUser.value.id) { database .getUserStats(D.ref, inCurrentWorld) diff --git a/src/stores/world.js b/src/stores/world.js index 821524bc..b4138bf7 100644 --- a/src/stores/world.js +++ b/src/stores/world.js @@ -13,6 +13,7 @@ import { replaceBioSymbols } from '../shared/utils'; import { instanceRequest, miscRequest, worldRequest } from '../api'; +import { patchWorldFromEvent } from '../query'; import { database } from '../service/database'; import { processBulk } from '../service/request'; import { useFavoriteStore } from './favorite'; @@ -76,8 +77,9 @@ export const useWorldStore = defineStore('World', () => { * @param {string} tag * @param {string} shortName */ - function showWorldDialog(tag, shortName = null) { + function showWorldDialog(tag, shortName = null, options = {}) { const D = worldDialog; + const forceRefresh = Boolean(options?.forceRefresh); const L = parseLocation(tag); if (L.worldId === '') { return; @@ -89,7 +91,7 @@ export const useWorldStore = defineStore('World', () => { shortName }); D.visible = true; - if (isMainDialogOpen && D.id === L.worldId) { + if (isMainDialogOpen && D.id === L.worldId && !forceRefresh) { uiStore.setDialogCrumbLabel('world', D.id, D.ref?.name || D.id); instanceStore.applyWorldDialogInstances(); nextTick(() => (D.loading = false)); @@ -141,10 +143,14 @@ export const useWorldStore = defineStore('World', () => { D.timeSpent = ref.timeSpent; } }); - worldRequest - .getCachedWorld({ - worldId: L.worldId - }) + const loadWorldRequest = forceRefresh + ? worldRequest.getWorld({ + worldId: L.worldId + }) + : worldRequest.getCachedWorld({ + worldId: L.worldId + }); + loadWorldRequest .catch((err) => { nextTick(() => (D.loading = false)); D.id = null; @@ -199,13 +205,6 @@ export const useWorldStore = defineStore('World', () => { } }); - if (args.cache) { - worldRequest.getWorld(args.params).then((args1) => { - if (D.id === args1.ref.id) { - updateVRChatWorldCache(); - } - }); - } } }); } @@ -343,6 +342,7 @@ export const useWorldStore = defineStore('World', () => { // update db cache database.addWorldToCache(ref); } + patchWorldFromEvent(ref); return ref; }