Skip to content

Commit b0835e8

Browse files
perf: reduce team page client payload with minimal data serialization (calcom#27656)
* perf: optimize team page data fetching by reducing host user data For team view pages (/team/[slug]), event type hosts only need minimal user data for avatar display (id, name, username, avatarUrl). Previously, the full userSelect was used which included: - teams (with nested team data) - credentials (with app and destinationCalendars) - email, bio This optimization reduces data transfer significantly for teams with many event types and hosts. With 441K requests and 54GB outgoing data (~128KB per request), even small reductions per request add up. The full userSelect is still used when !isTeamView to support the connectedApps feature. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: optimize team page serialization to reduce data transfer - Replace spread operators with explicit field selection for eventTypes - Only send minimal user data needed for UserAvatarGroup: name, username, avatarUrl, profile - Override eventTypes in return props with minimalEventTypes - Reduces per-request data transfer by excluding unnecessary fields like email, bio, teams, credentials Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: add missing fields needed by EventTypeDescription component Add metadata, seatsPerTimeSlot, requiresConfirmation to minimalEventTypes Add id to user object for proper key handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert: remove serialization changes that broke TypeScript types Keep only the query-level optimization in queries.ts which reduces database load by fetching minimal user data for event type hosts when isTeamView=true. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: reduce team page client payload with explicit DTO types - Create TeamPage.types.ts with explicit DTO types for minimal data - Update getServerSideProps.tsx to return only fields needed by the UI - Update team-view.tsx to use explicit types instead of inferSSRProps This reduces the data sent to the client by: - Event types: only id, title, slug, description, length, schedulingType, recurringEvent, metadata, requiresConfirmation, seatsPerTimeSlot - Event type users: only id, name, username, avatarUrl, avatar, profile - Members: only fields needed by Team component - Children: only slug and name - Parent: only id, slug, name, isOrganization, isPrivate, logoUrl Combined with the query-level optimization, this should significantly reduce the ~128KB average payload per request. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Revert: explicit DTO approach due to type compatibility issues The explicit DTO types broke compatibility with existing components (EventTypeDescription, UserAvatarGroup) which expect specific type structures. Keeping only the query-level optimization. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: reduce team page client payload with minimal data serialization - Update EventTypeDescription to accept minimal type (only fields actually used) - Update UserAvatarGroup to accept minimal user type - Update UserAvatar to accept minimal profile type - Update Team component to accept minimal member type - Update getServerSideProps to explicitly select only needed fields This reduces the ~128KB client payload by removing unused fields from: - Event types: removed hidden, price, currency, lockTimeZoneToggleOnBookingPage, etc. - Event type users: only send name, username, avatarUrl, avatar, profile.username, profile.organization.slug - Team members: only send fields needed by Team component - Team parent/children: only send fields needed for display Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert component type changes, keep serialization optimization - Revert EventTypeDescription, UserAvatarGroup, UserAvatar, Team component types to original - Keep serialization optimization in getServerSideProps.tsx - Add missing fields (price, currency, hidden, etc.) for EventTypeDescription compatibility - Add full profile structure for UserAvatarGroup compatibility This reduces client payload by explicitly selecting only needed fields while maintaining type compatibility with existing component usages. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: add missing fields for component type compatibility - Add lockedTimeZone and canSendCalVideoTranscriptionEmails for EventTypeDescription - Add organizationId to members for Team component MemberType - Add name, calVideoLogo, bannerUrl to organization for UserProfile type - Reorder fields to match baseEventTypeSelect structure Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 295058d commit b0835e8

2 files changed

Lines changed: 125 additions & 42 deletions

File tree

apps/web/lib/team/[slug]/getServerSideProps.tsx

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -164,41 +164,105 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
164164

165165
const isTeamOrParentOrgPrivate = team.isPrivate || (team.parent?.isOrganization && team.parent?.isPrivate);
166166

167-
team.eventTypes =
167+
const minimalEventTypes =
168168
team.eventTypes?.map((type) => ({
169-
...type,
169+
// Fields from baseEventTypeSelect (except description which becomes descriptionAsSafeHTML)
170+
id: type.id,
171+
title: type.title,
172+
slug: type.slug,
173+
length: type.length,
174+
schedulingType: type.schedulingType,
175+
recurringEvent: type.recurringEvent,
176+
hidden: type.hidden,
177+
price: type.price,
178+
currency: type.currency,
179+
lockTimeZoneToggleOnBookingPage: type.lockTimeZoneToggleOnBookingPage,
180+
lockedTimeZone: type.lockedTimeZone,
181+
requiresConfirmation: type.requiresConfirmation,
182+
requiresBookerEmailVerification: type.requiresBookerEmailVerification,
183+
canSendCalVideoTranscriptionEmails: type.canSendCalVideoTranscriptionEmails,
184+
seatsPerTimeSlot: type.seatsPerTimeSlot,
185+
// Additional fields needed by EventTypeDescription
186+
metadata: type.metadata,
187+
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
170188
users: !isTeamOrParentOrgPrivate
171189
? type.users.map((user) => ({
172-
...user,
190+
name: user.name,
191+
username: user.username,
192+
avatarUrl: user.avatarUrl,
173193
avatar: getUserAvatarUrl(user),
194+
profile: {
195+
id: user.profile.id,
196+
upId: user.profile.upId,
197+
username: user.profile.username,
198+
organizationId: user.profile.organizationId,
199+
organization: user.profile.organization
200+
? {
201+
id: user.profile.organization.id,
202+
slug: user.profile.organization.slug,
203+
name: user.profile.organization.name,
204+
requestedSlug: user.profile.organization.requestedSlug,
205+
calVideoLogo: user.profile.organization.calVideoLogo,
206+
bannerUrl: user.profile.organization.bannerUrl,
207+
}
208+
: null,
209+
},
174210
}))
175211
: [],
176-
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
177212
})) ?? null;
178213

179214
const safeBio = markdownToSafeHTML(team.bio) || "";
180215

181-
const members = !isTeamOrParentOrgPrivate
182-
? team.members.map((member) => {
183-
return {
184-
name: member.name,
185-
id: member.id,
186-
avatarUrl: member.avatarUrl,
187-
bio: member.bio,
188-
profile: member.profile,
189-
subteams: member.subteams,
190-
username: member.username,
191-
accepted: member.accepted,
192-
organizationId: member.organizationId,
193-
safeBio: markdownToSafeHTML(member.bio || ""),
194-
bookerUrl: getBookerBaseUrlSync(member.organization?.slug || ""),
195-
};
196-
})
216+
const minimalMembers = !isTeamOrParentOrgPrivate
217+
? team.members.map((member) => ({
218+
id: member.id,
219+
name: member.name,
220+
username: member.username,
221+
avatarUrl: member.avatarUrl,
222+
bio: member.bio,
223+
organizationId: member.organizationId,
224+
subteams: member.subteams,
225+
accepted: member.accepted,
226+
profile: {
227+
id: member.profile.id,
228+
username: member.profile.username,
229+
organizationId: member.profile.organizationId,
230+
organization: member.profile.organization
231+
? {
232+
id: member.profile.organization.id,
233+
slug: member.profile.organization.slug,
234+
name: member.profile.organization.name,
235+
requestedSlug: member.profile.organization.requestedSlug,
236+
calVideoLogo: member.profile.organization.calVideoLogo,
237+
bannerUrl: member.profile.organization.bannerUrl,
238+
}
239+
: null,
240+
},
241+
safeBio: markdownToSafeHTML(member.bio || ""),
242+
bookerUrl: getBookerBaseUrlSync(member.organization?.slug || ""),
243+
}))
197244
: [];
198245

199246
const markdownStrippedBio = stripMarkdown(team?.bio || "");
200247

201-
const serializableTeam = getSerializableTeam(team);
248+
const minimalParent = team.parent
249+
? {
250+
id: team.parent.id,
251+
slug: team.parent.slug,
252+
name: team.parent.name,
253+
isOrganization: team.parent.isOrganization,
254+
isPrivate: team.parent.isPrivate,
255+
logoUrl: team.parent.logoUrl,
256+
requestedSlug: teamMetadataSchema.parse(team.parent.metadata)?.requestedSlug ?? null,
257+
}
258+
: null;
259+
260+
const minimalChildren = isTeamOrParentOrgPrivate
261+
? []
262+
: team.children.map((child) => ({
263+
slug: child.slug,
264+
name: child.name,
265+
}));
202266

203267
// For a team or Organization we check if it's unpublished
204268
// For a subteam, we check if the parent org is unpublished. A subteam can't be unpublished in itself
@@ -211,21 +275,42 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
211275
return {
212276
props: {
213277
considerUnpublished: true,
214-
team: { ...serializableTeam },
278+
team: {
279+
id: team.id,
280+
slug: team.slug,
281+
name: team.name,
282+
isOrganization: team.isOrganization,
283+
logoUrl: team.logoUrl,
284+
metadata,
285+
parent: minimalParent,
286+
createdAt: null,
287+
},
215288
},
216289
} as const;
217290
}
218291

219292
return {
220293
props: {
221294
team: {
222-
...serializableTeam,
295+
id: team.id,
296+
slug: team.slug,
297+
name: team.name,
298+
bio: team.bio,
223299
safeBio,
224-
members,
300+
theme: team.theme,
301+
isPrivate: team.isPrivate,
302+
isOrganization: team.isOrganization,
303+
hideBookATeamMember: team.hideBookATeamMember,
304+
logoUrl: team.logoUrl,
305+
brandColor: team.brandColor,
306+
darkBrandColor: team.darkBrandColor,
225307
metadata,
226-
children: isTeamOrParentOrgPrivate ? [] : team.children,
308+
parent: minimalParent,
309+
eventTypes: minimalEventTypes,
310+
members: minimalMembers,
311+
children: minimalChildren,
227312
},
228-
themeBasis: serializableTeam.slug,
313+
themeBasis: team.slug,
229314
markdownStrippedBio,
230315
isValidOrgDomain,
231316
currentOrgDomain,
@@ -234,20 +319,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
234319
} as const;
235320
};
236321

237-
/**
238-
* Removes sensitive data from team and ensures that the object is serialiable by Next.js
239-
*/
240-
function getSerializableTeam(team: NonNullable<Awaited<ReturnType<typeof getTeamWithMembers>>>) {
241-
const { inviteToken: _inviteToken, ...serializableTeam } = team;
242-
243-
const teamParent = team.parent ? getTeamWithoutMetadata(team.parent) : null;
244-
245-
return {
246-
...serializableTeam,
247-
parent: teamParent,
248-
};
249-
}
250-
251322
/**
252323
* Removes metadata from team and just adds requestedSlug
253324
*/
@@ -256,7 +327,6 @@ function getTeamWithoutMetadata<T extends Pick<Team, "metadata">>(team: T) {
256327
const teamMetadata = teamMetadataSchema.parse(metadata);
257328
return {
258329
...rest,
259-
// add requestedSlug if available.
260330
...(typeof teamMetadata?.requestedSlug !== "undefined"
261331
? { requestedSlug: teamMetadata?.requestedSlug }
262332
: {}),

packages/features/ee/teams/lib/queries.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ export async function getTeamWithMembers(args: {
4040

4141
// This should improve performance saving already app data found.
4242
const appDataMap = new Map();
43+
44+
// Minimal user select for event type hosts in team view - only fields needed for avatar display
45+
// This significantly reduces data transfer for teams with many event types/hosts
46+
const minimalUserSelectForHosts = {
47+
username: true,
48+
name: true,
49+
avatarUrl: true,
50+
id: true,
51+
} satisfies Prisma.UserSelect;
52+
53+
// Full user select for members (includes credentials for connectedApps when !isTeamView)
4354
const userSelect = {
4455
username: true,
4556
email: true,
@@ -156,7 +167,9 @@ export async function getTeamWithMembers(args: {
156167
hosts: {
157168
select: {
158169
user: {
159-
select: userSelect,
170+
// For team view, we only need minimal user info for avatar display
171+
// This significantly reduces data transfer for teams with many event types/hosts
172+
select: isTeamView ? minimalUserSelectForHosts : userSelect,
160173
},
161174
},
162175
},

0 commit comments

Comments
 (0)