Skip to content

Commit a9bd71a

Browse files
authored
perf(dashboard): migrate to useAsyncData with SSR hydration and seed (#169)
### 🔗 Linked issue <!-- No open issue — performance improvement initiative from internal profiling --> ### 🧭 Context Loading `/guilds/:id/manage` was slower than needed because the dashboard layout fetched all guild data on the client (`onMounted`), blocking the first render and causing a visible loading flash on every page navigation. This PR migrates the dashboard layout to SSR-aware data fetching via `useAsyncData` and ships the test infrastructure to validate async data and concurrency patterns. The previous commits on this branch delivered the server-side half of this work: - N+1 Discord API fan-out eliminated with `mapWithConcurrency(limit=8)` and `includeChannels: false` for non-channel contexts - Sequential `getCurrentUser` fan-out replaced with `Promise.all` - Duplicate `readSettings` DB call eliminated across guild handlers - Uncached `api.guilds.getChannels` replaced with a cached helper - `Cache-Control: private, max-age=30, stale-while-revalidate=300` added to `/api/users` This commit delivers the client/SSR half. ### 📚 Description **`app/layouts/dashboard.vue`** - Replaced the `onMounted` dual-fetch pattern with `useAsyncData` so the server renders the sidebar on first load instead of showing a skeleton - Key is a reactive factory `() => \`dashboard:guild:${guildId.value}\`` so switching guilds triggers an automatic refetch without a full navigation - Added `useRequestFetch()` to forward auth cookies when the handler runs on the server (required for `nuxt-auth-utils` session resolution in SSR) - Sidebar is seeded from `userGuilds.value?.find(g => g.id === guildId.value)` before the async data resolves, preventing a layout shift where the guild name flickers - `pending` replaces the manual `isLoading` ref; error watch (`401`/`403`/default) is preserved unchanged **Test infrastructure (`test/`)** - `test/mocks/discord.ts` — Added typed mock factories: `mockGetGuild`, `mockGetGuildChannels`, `mockGetMember`, `mockReadSettings`, using `APIGuild`, `APIGuildMember`, `APIChannel` from `discord-api-types/v10` - `test/fixtures/oauth-guilds.ts` — `FIXTURE_OAUTH_GUILDS`: 50-entry `RESTAPIPartialCurrentUserGuild[]` array for use in concurrency and pagination tests - `test/nuxt/dashboard-layout.spec.ts` — Extended with async data watcher tests covering guild-switch refetch, SSR hydration path, and error handling branches All 424 unit tests pass.
2 parents 91caae8 + 5981107 commit a9bd71a

24 files changed

Lines changed: 1288 additions & 130 deletions

app/layouts/dashboard.vue

Lines changed: 150 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,17 @@ import { isNullOrUndefinedOrZero, objectValues } from "@sapphire/utilities";
168168
import { isNullOrUndefined } from "@sapphire/utilities/isNullish";
169169
import { objectToTuples } from "@sapphire/utilities/objectToTuples";
170170
import { parseError, createError } from "evlog";
171+
import { parseGuildSettings, classifyGuildError } from "~/utils/guild-dashboard";
172+
173+
function isSafeUrl(url: unknown): url is string {
174+
if (typeof url !== "string") return false;
175+
try {
176+
const { protocol } = new URL(url);
177+
return protocol === "https:";
178+
} catch {
179+
return false;
180+
}
181+
}
171182
172183
const logger = useLogger("wolfstar:dashboard");
173184
@@ -190,7 +201,145 @@ const { setGuildData, guildData } = useGuildData();
190201
const { setGuildSettings, guildSettings } = useGuildSettings();
191202
const { setGuildSettingsChanges, guildSettingsChanges, resetGuildSettingsChanges } =
192203
useGuildSettingsChanges();
193-
const isLoading = useState<boolean>("dashboard:loading", () => true);
204+
205+
const { user } = useUserSession();
206+
const { guilds: userGuilds } = useUser(user);
207+
watch(
208+
[guildId, userGuilds],
209+
([newGuildId, newUserGuilds]) => {
210+
const seedGuild = newUserGuilds?.find((g) => g.id === newGuildId);
211+
if (seedGuild) {
212+
setGuildData(seedGuild);
213+
}
214+
},
215+
{ immediate: true },
216+
);
217+
218+
const requestFetch = useRequestFetch();
219+
220+
const {
221+
data,
222+
pending: isLoading,
223+
error,
224+
} = useAsyncData(
225+
() => `dashboard:guild:${guildId.value}`,
226+
() =>
227+
Promise.all([
228+
requestFetch<ValuesType<NonNullable<TransformedLoginData["transformedGuilds"]>>>(
229+
`/api/guilds/${guildId.value}`,
230+
),
231+
requestFetch<string>(`/api/guilds/${guildId.value}/settings`),
232+
]),
233+
);
234+
235+
watch(
236+
data,
237+
(newData) => {
238+
if (newData) {
239+
setGuildData(newData[0]);
240+
241+
const parsedSettings = parseGuildSettings(
242+
newData[1],
243+
guildSettings.value ?? {},
244+
(parseErr) => {
245+
logger.error(
246+
`Failed to parse guild settings payload for guild Id: ${guildId.value}`,
247+
parseError(parseErr),
248+
);
249+
},
250+
);
251+
252+
setGuildSettings(parsedSettings as GuildData);
253+
254+
if (nuxtError.value) {
255+
clearError();
256+
}
257+
}
258+
},
259+
{ immediate: true },
260+
);
261+
262+
watch(
263+
error,
264+
async (err) => {
265+
if (err) {
266+
const parsedError = parseError(err);
267+
268+
logger.error(
269+
`Error loading guild data or settings for guild Id: ${guildId.value}`,
270+
parsedError,
271+
);
272+
273+
switch (classifyGuildError(parsedError.status)) {
274+
case "forbidden": {
275+
if (import.meta.client) {
276+
toast.add({
277+
title: "Access Denied",
278+
description:
279+
"You don't have permission to access this server's dashboard.",
280+
color: "error",
281+
icon: "heroicons:x-circle",
282+
});
283+
}
284+
if (import.meta.client && window.history.length > 1) {
285+
router.back();
286+
} else {
287+
await navigateTo("/");
288+
}
289+
break;
290+
}
291+
case "unauthorized": {
292+
if (import.meta.client) {
293+
toast.add({
294+
title: "Unauthorized",
295+
description:
296+
"Your session has expired or you are not authorized. Please log in again to access the dashboard.",
297+
color: "error",
298+
icon: "heroicons:x-circle",
299+
});
300+
}
301+
if (import.meta.client && window.history.length > 1) {
302+
router.back();
303+
} else {
304+
await navigateTo("/");
305+
}
306+
break;
307+
}
308+
default: {
309+
if (import.meta.client) {
310+
const link = isSafeUrl(parsedError.link) ? parsedError.link : null;
311+
toast.add({
312+
title: parsedError.message,
313+
description: parsedError.why,
314+
color: "error",
315+
actions: link
316+
? [
317+
{
318+
label: "Learn more",
319+
onClick: () => {
320+
window.open(link, "_blank", "noopener,noreferrer");
321+
},
322+
},
323+
]
324+
: undefined,
325+
icon: "heroicons:x-circle",
326+
});
327+
}
328+
showError({
329+
status: parsedError.status || 500,
330+
message: parsedError.message,
331+
data: {
332+
why: parsedError.why,
333+
fix: parsedError.fix,
334+
link: parsedError.link,
335+
},
336+
});
337+
}
338+
}
339+
}
340+
},
341+
{ immediate: true },
342+
);
194343
195344
const items = computed<NavigationMenuItem[][]>(() => [
196345
[
@@ -396,87 +545,4 @@ watch(guildId, (newGuildId, oldGuildId) => {
396545
);
397546
}
398547
});
399-
400-
onMounted(async () => {
401-
isLoading.value = true;
402-
403-
try {
404-
// Fetch guild data and settings in parallel to halve round-trip latency.
405-
const [guildData, guildSettings] = await Promise.all([
406-
$fetch<ValuesType<NonNullable<TransformedLoginData["transformedGuilds"]>>>(
407-
`/api/guilds/${guildId.value}`,
408-
),
409-
$fetch<string>(`/api/guilds/${guildId.value}/settings`),
410-
]);
411-
412-
setGuildData(guildData);
413-
setGuildSettings(JSON.parse(guildSettings));
414-
415-
if (nuxtError.value) {
416-
clearError();
417-
}
418-
// oxlint-disable-next-line unicorn/catch-error-name
419-
} catch (err: unknown) {
420-
const error = parseError(err);
421-
422-
logger.error(`Error loading guild data or settings for guild Id: ${guildId.value}`, error);
423-
424-
switch (error.status) {
425-
case 403: {
426-
toast.add({
427-
title: "Access Denied",
428-
description: "You don't have permission to access this server's dashboard.",
429-
color: "error",
430-
icon: "heroicons:x-circle",
431-
});
432-
if (import.meta.client && window.history.length > 1) {
433-
router.back();
434-
} else {
435-
await navigateTo("/");
436-
}
437-
break;
438-
}
439-
case 401: {
440-
toast.add({
441-
title: "Unauthorized",
442-
description:
443-
"Your session has expired or you are not authorized. Please log in again to access the dashboard.",
444-
color: "error",
445-
icon: "heroicons:x-circle",
446-
});
447-
if (import.meta.client && window.history.length > 1) {
448-
router.back();
449-
} else {
450-
await navigateTo("/");
451-
}
452-
break;
453-
}
454-
default: {
455-
toast.add({
456-
title: error.message,
457-
description: error.why,
458-
color: "error",
459-
actions: error.link
460-
? [
461-
{
462-
label: "Learn more",
463-
onClick: () => {
464-
window.open(error.link);
465-
},
466-
},
467-
]
468-
: undefined,
469-
icon: "heroicons:x-circle",
470-
});
471-
showError({
472-
status: error.status || 500,
473-
message: error.message,
474-
data: { why: error.why, fix: error.fix, link: error.link },
475-
});
476-
}
477-
}
478-
} finally {
479-
isLoading.value = false;
480-
}
481-
});
482548
</script>

app/utils/guild-dashboard.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type GuildErrorClass = "forbidden" | "unauthorized" | "default";
2+
3+
/**
4+
* Maps an HTTP status code from a guild dashboard fetch error to one of three
5+
* handled cases so the watcher in `dashboard.vue` can be tested without
6+
* mounting the full layout.
7+
*/
8+
export function classifyGuildError(status: number | undefined): GuildErrorClass {
9+
if (status === 403) return "forbidden";
10+
if (status === 401) return "unauthorized";
11+
return "default";
12+
}
13+
14+
/**
15+
* Safely parses a raw JSON string of guild settings. Falls back to
16+
* `fallback` when the string is malformed or when the parsed value is not a
17+
* plain object (e.g. an array or primitive). The optional `onError` callback
18+
* is invoked with the caught error so callers can log it without coupling this
19+
* pure helper to any logging infrastructure.
20+
*
21+
* **Note:** the plain-object check uses `Object.getPrototypeOf(parsed) === Object.prototype`
22+
* intentionally — objects with null prototypes or non-plain class instances are rejected in
23+
* favour of the `fallback`, since guild settings are always plain JSON objects.
24+
*/
25+
export function parseGuildSettings(
26+
raw: string,
27+
fallback: Record<string, unknown>,
28+
onError?: (err: unknown) => void,
29+
): Record<string, unknown> {
30+
try {
31+
const parsed: unknown = JSON.parse(raw);
32+
if (
33+
typeof parsed === "object" &&
34+
parsed !== null &&
35+
!Array.isArray(parsed) &&
36+
Object.getPrototypeOf(parsed) === Object.prototype
37+
) {
38+
return parsed as Record<string, unknown>;
39+
}
40+
return fallback;
41+
} catch (err) {
42+
onError?.(err);
43+
return fallback;
44+
}
45+
}

nuxt.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ export default defineNuxtConfig({
181181
"/": { appLayout: "default", prerender: true, robots: true },
182182
"/_og/d/**": getISRConfig(60 * 60 * 24), // 1 day
183183
"/api/auth/**": { isr: false, cache: false },
184+
"/api/users": {
185+
headers: {
186+
"Cache-Control": "private, max-age=30, stale-while-revalidate=300",
187+
"Vary": "Cookie, Authorization",
188+
},
189+
},
190+
184191
"/oauth/**": {
185192
robots: "nosnippet,notranslate,noimageindex,noarchive,max-snippet:-1,max-image-preview:none,max-video-preview:-1",
186193
security: {

server/api/guilds/[guild]/channels/[channel].get.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ export default defineWrappedResponseHandler(
99
log.set({ guild: { id: guildId } });
1010

1111
const guild = await getGuild(guildId);
12+
if (!guild) {
13+
throw createError({
14+
message: "Guild not found",
15+
status: 404,
16+
why: `The bot is not a member of guild ${guildId}`,
17+
fix: "check bot is a member of the guild",
18+
});
19+
}
1220

1321
const currentMember = await getCurrentMember(event, guild.id);
1422
log.set({ member: { id: currentMember.user.id } });

server/api/guilds/[guild]/channels/index.get.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ export default defineWrappedResponseHandler(
99
log.set({ guild: { id: guildId } });
1010

1111
const guild = await getGuild(guildId);
12+
if (!guild) {
13+
throw createError({
14+
message: "Guild not found",
15+
status: 404,
16+
why: `The bot is not a member of guild ${guildId}`,
17+
fix: "check bot is a member of the guild",
18+
});
19+
}
1220

1321
const member = await getCurrentMember(event, guild.id);
1422
log.set({ member: { id: member.user.id } });

0 commit comments

Comments
 (0)