add @tanstack/query

This commit is contained in:
pa
2026-03-06 18:14:24 +09:00
parent 7d2bb022a4
commit e665b3815d
40 changed files with 2171 additions and 232 deletions

144
package-lock.json generated
View File

@@ -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": {

View File

@@ -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"
},

View File

@@ -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);
});
});

View File

@@ -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'
});
});
});

View File

@@ -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'
});
});
});

View File

@@ -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'
});
});
});

View File

@@ -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
});
});
});

View File

@@ -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;
});
},

View File

@@ -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
}));
}
};

View File

@@ -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;
});
},

View File

@@ -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}

View File

@@ -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;
});
},

View File

@@ -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;
});
}

View File

@@ -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;
});
},

View File

@@ -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;
});
},

View File

@@ -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;
});
}

View File

@@ -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;
});
}

View File

@@ -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;
});
},

View File

@@ -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);

View File

@@ -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'));

View File

@@ -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;

View File

@@ -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;
}
}
/**

View File

@@ -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);

View File

@@ -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
);
});
});

View File

@@ -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);
});
});

View File

@@ -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
});
});
});

11
src/query/client.js Normal file
View File

@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/vue-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: true
}
}
});

230
src/query/entityCache.js Normal file
View File

@@ -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<any>}} 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<void>}
*/
export async function refetchActiveEntityQuery(queryKey) {
await queryClient.invalidateQueries({
queryKey,
exact: true,
refetchType: 'active'
});
}
/**
* @param {{queryKey: unknown[], nextData: any}} options
* @returns {Promise<void>}
*/
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
};

14
src/query/index.js Normal file
View File

@@ -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';

149
src/query/keys.js Normal file
View File

@@ -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]
});

97
src/query/policies.js Normal file
View File

@@ -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
};
}

View File

@@ -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)
});
}

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;
}