fix(fe2): various performance fixes (#5278)

* fix(fe2): buggy merge policies causing cache read failures

* improved CWV reporting

* improved preview lcp

* more eager load improvements

* more eager load improvements

* SSR friendly relative time

* import fixes
This commit is contained in:
Kristaps Fabians Geikins
2025-08-21 07:50:57 +03:00
committed by GitHub
parent f4382c9664
commit 47b9e9c5bc
32 changed files with 200 additions and 46 deletions
@@ -180,12 +180,14 @@ const hasDiscoverableWorkspacesOrJoinRequests = computed(
() => discoverableWorkspacesAndJoinRequestsCount.value > 0
)
onActiveWorkspaceResult(({ data }) => {
if (data?.activeUser?.activeWorkspace) {
$intercom.updateCompany({
id: data.activeUser.activeWorkspace.id,
name: data.activeUser.activeWorkspace.name
})
}
})
if (import.meta.client) {
onActiveWorkspaceResult(({ data }) => {
if (data?.activeUser?.activeWorkspace) {
$intercom.updateCompany({
id: data.activeUser.activeWorkspace.id,
name: data.activeUser.activeWorkspace.name
})
}
})
}
</script>
@@ -89,9 +89,11 @@ const props = withDefaults(
defineProps<{
previewUrl: string
panoramaOnHover?: boolean
eagerLoad?: boolean
}>(),
{
panoramaOnHover: true
panoramaOnHover: true,
eagerLoad: true
}
)
@@ -109,7 +111,10 @@ const {
isLoadingPanorama,
hasDoneFirstLoad,
isPanoramaPlaceholder
} = usePreviewImageBlob(basePreviewUrl, { enabled: isInViewport })
} = usePreviewImageBlob(basePreviewUrl, {
enabled: isInViewport,
eagerLoad: props.eagerLoad
})
const hovered = ref(false)
const panorama = ref(null as Nullable<HTMLDivElement>)
@@ -171,6 +171,7 @@ const props = defineProps<{
selected?: boolean
}>()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const isAutomateModuleEnabled = useIsAutomateModuleEnabled()
const showActionsMenu = ref(false)
@@ -74,6 +74,7 @@ const props = defineProps<{
const { screenshot } = useCommentScreenshotImage(
computed(() => props.thread.screenshot)
)
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const isLimited = computed(() => {
return !props.thread.rawText
@@ -57,6 +57,7 @@ const props = defineProps<{
const { backgroundImage } = useCommentScreenshotImage(
computed(() => props.thread.screenshot)
)
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const updatedAt = computed(() => {
return {
@@ -339,6 +339,7 @@ const props = defineProps<{
}>()
const router = useRouter()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const importArea = ref(
null as Nullable<{
@@ -146,6 +146,7 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { required: true })
const { copy } = useClipboard()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const { getErrorMessage, convertUploadToFailedJob } = useFailedFileImportJobUtils()
const {
@@ -7,15 +7,16 @@
</div>
</template>
<script setup lang="ts">
import { formattedFullDate } from '~/utils/dateFormatter'
/**
* Separate component so that hydration mismatches only cause this component to re-render, not the entire model card.
* Hydration mismatches can happen here when the server resolves the update as X minutes ago, but the client resolves it as X minutes and 1 second ago.
*
* @deprecated Formatted dates now use a SSR-friendly now() date, so this doesnt need to be used anymore
*/
const props = defineProps<{
updatedAt: string
}>()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const updatedAtFormatted = computed(() => {
return {
@@ -66,7 +66,6 @@ import { TrashIcon } from '@heroicons/vue/24/outline'
import { projectEmbedTokensQuery } from '~~/lib/projects/graphql/queries'
import type { EmbedTokenItem } from '~~/lib/projects/helpers/types'
import { graphql } from '~/lib/common/generated/gql'
import { formattedFullDate } from '~/utils/dateFormatter'
import { useDeleteEmbedToken } from '~~/lib/projects/composables/tokenManagement'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import type { Nullable } from '@speckle/shared'
@@ -107,6 +106,7 @@ graphql(`
}
`)
const { formattedFullDate } = useDateFormatters()
const route = useRoute()
const projectId = computed(() => route.params.id as string)
const deleteEmbedToken = useDeleteEmbedToken()
@@ -143,6 +143,7 @@ const props = defineProps<{
const router = useRouter()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const isModelUploading = ref(false)
@@ -167,6 +167,7 @@ const props = defineProps<{
workspace: MaybeNullOrUndefined<SettingsSharedProjects_WorkspaceFragment>
}>()
const { formattedFullDate } = useDateFormatters()
const { activeUser } = useActiveUser()
const canCreatePersonal = useCanCreatePersonalProject({
activeUser: computed(() => activeUser.value)
@@ -133,6 +133,7 @@ const props = defineProps<{
workspaceSlug: string
}>()
const { formattedFullDate } = useDateFormatters()
const { result } = useQuery(settingsWorkspacesMembersTableQuery, () => ({
slug: props.workspaceSlug
}))
@@ -129,6 +129,7 @@ const props = defineProps<{
const search = ref('')
const showActionsMenu = ref<Record<string, boolean>>({})
const { formattedFullDate } = useDateFormatters()
const cancelInvite = useCancelWorkspaceInvite()
const resendInvite = useResendWorkspaceInvite()
const { result: searchResult, loading: searchResultLoading } = useQuery(
@@ -99,6 +99,7 @@ const props = defineProps<{
}>()
const { deny } = useWorkspaceJoinRequest()
const { formattedFullDate } = useDateFormatters()
const showApproveJoinRequestDialog = ref(false)
const requestToApprove =
@@ -146,6 +146,7 @@ const { activeUser } = useActiveUser()
const { result } = useQuery(settingsWorkspacesMembersTableQuery, () => ({
slug: props.workspaceSlug
}))
const { formattedFullDate } = useDateFormatters()
const selectedAction = ref<Record<string, WorkspaceUserActionTypes>>({})
const search = ref('')
@@ -78,6 +78,7 @@ const { getErrorMessage } = useFailedFileImportJobUtils()
const { copyReference } = useGenerateErrorReference()
const navigateToProject = useNavigateToProject()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const open = computed({
get: () => failedJobs.value.length > 0,
@@ -59,6 +59,7 @@ const {
response: { project }
}
} = useInjectedViewerState()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const createdAt = computed(() => {
return {
@@ -119,6 +119,7 @@ const open = (id: string) => {
const { calculateThreadResourceStatus } = useCommentContext()
const itemStatus = computed(() => calculateThreadResourceStatus(props.thread))
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const createdAt = computed(() => {
return {
@@ -24,6 +24,7 @@ const props = defineProps<{
version: ViewerModelVersionCardItemFragment
isNewest: boolean
}>()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const createdAt = computed(() => {
return {
@@ -141,6 +141,7 @@ const {
}
} = useInjectedViewerState()
const mp = useMixpanel()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const route = useRoute()
const resourceIdString = computed(() => {
@@ -33,6 +33,7 @@ interface Version {
const props = defineProps<{
version: Version
}>()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const createdAt = computed(() => {
return {
@@ -121,6 +121,7 @@ const {
response: { project }
}
} = useInjectedViewerState()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const showVersions = ref(!!props.initiallyExpanded)
const showDeleteDialog = ref(false)
@@ -142,6 +142,7 @@ const {
}
} = useInjectedViewerState()
const copyModelLink = useCopyModelLink()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const isLoaded = computed(() => props.isLoadedVersion)
const isLatest = computed(() => props.isLatestVersion)
@@ -158,6 +158,7 @@ const updateView = useUpdateSavedView()
const isLoading = useMutationLoading()
const { copyLink, applyView } = useViewerSavedViewsUtils()
const eventBus = useEventBus()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const showMenu = ref(false)
const menuId = useId()
@@ -7,9 +7,14 @@ import dayjs from 'dayjs'
* customRelativeTime('2023-07-16') - returns "Jul 16" or "Jul 16, 2023" if the year is different from the current year
* customRelativeTime(new Date()) - returns "just now"
*/
const customRelativeTime = (date: ConfigType, capitalize?: boolean): string => {
const customRelativeTime = (
date: ConfigType,
now: ConfigType,
capitalize?: boolean
): string => {
const pastDate = dayjs(date)
const now = dayjs()
now = dayjs(now)
const diffInMinutes = now.diff(date, 'minute')
const diffInHours = now.diff(date, 'hour')
const diffInDays = now.diff(date, 'day')
@@ -35,8 +40,8 @@ const customRelativeTime = (date: ConfigType, capitalize?: boolean): string => {
* isTimeframe('2023-07-16') - returns false
* isTimeframe(new Date()) - returns true or false depending on the current time
*/
const isTimeframe = (date: ConfigType) => {
const unit = customRelativeTime(date)
const isTimeframe = (date: ConfigType, now: ConfigType) => {
const unit = customRelativeTime(date, now)
return (
unit.includes('second') ||
unit.includes('minute') ||
@@ -51,7 +56,7 @@ const isTimeframe = (date: ConfigType) => {
* @example
* formattedFullDate('2023-12-01') - returns "Dec 12, 2023"
*/
export const formattedFullDate = (date: ConfigType): string =>
const formattedFullDate = (date: ConfigType): string =>
dayjs(date).format('MMM D, YYYY, H:mm')
/**
@@ -63,15 +68,51 @@ export const formattedFullDate = (date: ConfigType): string =>
* formattedRelativeDate('2023-12-31') - returns "1 day ago"
* formattedRelativeDate('2023-12-31', { prefix: true }) - returns "1 day ago"
*/
export const formattedRelativeDate = (
const formattedRelativeDate = (
date: ConfigType,
now: ConfigType,
options?: Partial<{ prefix: boolean; capitalize: boolean }>
): string => {
if (options?.prefix) {
return isTimeframe(date)
? customRelativeTime(date, options?.capitalize)
: `on ${customRelativeTime(date)}`
return isTimeframe(date, now)
? customRelativeTime(date, now, options?.capitalize)
: `on ${customRelativeTime(date, now)}`
} else {
return customRelativeTime(date, options?.capitalize)
return customRelativeTime(date, now, options?.capitalize)
}
}
// Remembering and reusing same now() value in SSR and CSR to avoid hydration mismatches
const useNowState = () => useState('now', () => new Date())
export const useDateFormatters = () => {
const state = useNowState()
const { $isAppHydrated } = useNuxtApp()
return {
/**
* Formats a given date input into a relative time string with optional prefix
* @example
* Assuming today is January 1st 2024
* formattedRelativeDate('2023-12-01') - returns "Dec 12, 2023"
* formattedRelativeDate('2023-12-01', { prefix: true }) - returns "on Dec 12, 2023"
* formattedRelativeDate('2023-12-31') - returns "1 day ago"
* formattedRelativeDate('2023-12-31', { prefix: true }) - returns "1 day ago"
*/
formattedRelativeDate: (
date: ConfigType,
options?: Partial<{ prefix: boolean; capitalize: boolean; now: ConfigType }>
): string => {
// during SSR and hydration use static now, afterwards use a fresh one each time
// (unless if specific one fed in)
const now = options?.now || ($isAppHydrated.value ? new Date() : state.value)
return formattedRelativeDate(date, now, options)
},
/**
* Formats a given date input into a full date string with our default format
* @example
* formattedFullDate('2023-12-01') - returns "Dec 12, 2023"
*/
formattedFullDate
}
}
@@ -321,6 +321,9 @@ function createCache(): InMemoryCache {
ServerConfiguration: {
merge: true
},
WorkspaceSubscription: {
merge: true
},
CommentThreadActivityMessage: {
merge: true
},
@@ -354,7 +357,7 @@ function createCache(): InMemoryCache {
Workspace: {
fields: {
invitedTeam: {
merge: (_existing, incoming) => incoming
merge: incomingOverwritesExistingMergeFunction
},
team: {
keyArgs: ['limit', 'filter', ['roles', 'search', 'seatType']],
@@ -113,6 +113,7 @@ export function buildAbstractCollectionMergeFunction<T extends string>(
}
return {
...(existing || {}),
...(incoming || {}),
...(finalItems ? { items: finalItems } : {}),
__typename: incoming?.__typename || existing?.__typename || typeName
@@ -132,7 +133,8 @@ export const incomingOverwritesExistingMergeFunction: FieldMergeFunction = (
incoming: unknown
) => incoming
export const mergeAsObjectsFunction: FieldMergeFunction = (existing, incoming) => ({
...existing,
...incoming
})
export const mergeAsObjectsFunction: FieldMergeFunction = (
existing,
incoming,
{ mergeObjects }
) => mergeObjects(existing, incoming) // built in apollo logic is more foolproof
@@ -5,6 +5,20 @@ import { useSubscription } from '@vue/apollo-composable'
import { useLock } from '~~/lib/common/composables/singleton'
import PreviewPlaceholder from '~~/assets/images/preview_placeholder.png'
import { isValidBase64Image } from '@speckle/shared/images/base64'
import { nanoid } from 'nanoid'
/**
* Eager loading previews ensures a better LCP score, but also hits the preview endpoint more often.
* Since we don't know the viewport size in SSR, we can just set a limit of how many previews to eager
* load after which they should use spinners.
*
* Theoretically even 1 eager load will fix the LCP issue, but it will look odd if all of the other ones
* show up as spinners. So ideally just set enough for 1 page load.
*
* Assuming a large screen w/ the busiest preview page (project page w/ model grid), there would be
* about 20 previews
*/
const PREVIEWS_EAGER_LOAD_COUNT = 20
const previewUrlProjectIdRegexp = /\/preview\/([\w\d]+)\//i
const previewUrlCommitIdRegexp = /\/commits\/([\w\d]+)/i
@@ -12,6 +26,14 @@ const previewUrlObjectIdRegexp = /\/commits\/([\w\d]+)/i
class AngleNotFoundError extends Error {}
const usePreviewsState = () =>
useState('preview_images_load_state', () => ({
/**
* How many previews have already been eager loaded
*/
eagerLoadedKeys: new Set<string>()
}))
/**
* Get authenticated preview image URL and subscribes to preview image generation events so that the preview image URL
* is updated whenever generation finishes
@@ -23,18 +45,47 @@ export function usePreviewImageBlob(
* Allows disabling the mechanism conditionally (e.g. if image not in viewport)
*/
enabled: MaybeRef<boolean>
/**
* Whether to avoid spinners and just embed the image immediately. Means we will likely
* load a lot more than needed, but also get a way better LCP score (no spinners).
*
* If enabled, overrides `enabled` to be true.
*/
eagerLoad: boolean
}>
) {
// Checking if we're allowed to eager load
const { $isAppHydrated } = useNuxtApp()
const state = usePreviewsState()
const eagerLoad =
options?.eagerLoad &&
!$isAppHydrated.value &&
state.value.eagerLoadedKeys.size < PREVIEWS_EAGER_LOAD_COUNT
const eagerLoadKey = nanoid()
if (eagerLoad) {
state.value.eagerLoadedKeys.add(eagerLoadKey)
}
// Continue on with normal operation
const { enabled = ref(true) } = options || {}
const logger = useLogger()
const lazyLoad = !eagerLoad
const url = ref(PreviewPlaceholder as Nullable<string>)
const hasDoneFirstLoad = ref(false)
const url = ref<Nullable<string>>(
(eagerLoad ? unref(previewUrl) : PreviewPlaceholder) || null
)
const hasDoneFirstLoad = ref(eagerLoad)
const panoramaUrl = ref(null as Nullable<string>)
const isLoadingPanorama = ref(false)
const shouldLoadPanorama = ref(false)
const basePanoramaUrl = computed(() => unref(previewUrl) + '/all')
const isEnabled = computed(() => (import.meta.server ? true : unref(enabled)))
const isEnabled = computed(() => {
if (import.meta.server) return true // always true on server
if (eagerLoad) return true // always true if eagerLoad
return unref(enabled)
})
const cacheBust = ref(0)
const isPanoramaPlaceholder = ref(false)
@@ -137,7 +188,7 @@ export function usePreviewImageBlob(
const blobUrl = blobUrlConfig.toString()
// Load img in browser first, before we set the url
if (import.meta.client) {
if (import.meta.client && lazyLoad) {
const img = new Image()
img.src = blobUrl
await new Promise((resolve, reject) => {
@@ -200,10 +251,12 @@ export function usePreviewImageBlob(
}
}
const regeneratePreviews = (basePreviewUrl?: string) => {
const regeneratePreviews = async (basePreviewUrl?: string) => {
cacheBust.value++
processBasePreviewUrl(basePreviewUrl || unref(previewUrl))
if (shouldLoadPanorama.value) processPanoramaPreviewUrl()
await Promise.all([
processBasePreviewUrl(basePreviewUrl || unref(previewUrl)),
...(shouldLoadPanorama.value ? [processPanoramaPreviewUrl()] : [])
])
}
watch(shouldLoadPanorama, (newVal) => {
@@ -213,7 +266,7 @@ export function usePreviewImageBlob(
watch(
() => unref(previewUrl),
(newVal) => {
regeneratePreviews(newVal || undefined)
void regeneratePreviews(newVal || undefined)
},
{ immediate: true }
)
@@ -223,7 +276,7 @@ export function usePreviewImageBlob(
(newVal) => {
if (!newVal) return
regeneratePreviews()
void regeneratePreviews()
}
)
+1 -1
View File
@@ -56,7 +56,7 @@
"@tiptap/vue-3": "2.10.3",
"@tryghost/content-api": "^1.11.21",
"@vue/apollo-composable": "^4.2.2",
"@vue/apollo-ssr": "4.0.0",
"@vue/apollo-ssr": "4.2.2",
"@vueuse/core": "^10.9.0",
"apollo-upload-client": "^18.0.1",
"dayjs": "^1.11.7",
@@ -0,0 +1,18 @@
/**
* Global ref for checking if app has hydrated
*/
export default defineNuxtPlugin((nuxtApp) => {
const isAppHydrated = ref(false)
if (import.meta.client) {
nuxtApp.hook('app:suspense:resolve', () => {
isAppHydrated.value = true
})
}
return {
provide: {
isAppHydrated
}
}
})
+12 -3
View File
@@ -126,18 +126,27 @@ async function initRumServer(app: PluginNuxtApp) {
app.hook('app:rendered', (context) => {
context.ssrContext!.head.push({
script: [
{
innerHTML: `
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
// last.startTime is your current LCP candidate time
console.log('LCP candidate:', last.startTime, last);
}).observe({ type: 'largest-contentful-paint', buffered: true });
`,
type: 'module'
},
{
innerHTML: `
import {
onCLS,
onFID,
onLCP,
onINP,
onTTFB
} from 'https://cdn.jsdelivr.net/npm/web-vitals@3/dist/web-vitals.attribution.js?module';
} from 'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.js?module';
onCLS(console.log);
onFID(console.log);
onLCP(console.log);
onINP(console.log);
onTTFB(console.log);
+5 -5
View File
@@ -15981,7 +15981,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": "npm:^7.12.0"
"@typescript-eslint/parser": "npm:^7.12.0"
"@vue/apollo-composable": "npm:^4.2.2"
"@vue/apollo-ssr": "npm:4.0.0"
"@vue/apollo-ssr": "npm:4.2.2"
"@vueuse/core": "npm:^10.9.0"
apollo-upload-client: "npm:^18.0.1"
autoprefixer: "npm:^10.4.14"
@@ -20646,12 +20646,12 @@ __metadata:
languageName: node
linkType: hard
"@vue/apollo-ssr@npm:4.0.0":
version: 4.0.0
resolution: "@vue/apollo-ssr@npm:4.0.0"
"@vue/apollo-ssr@npm:4.2.2":
version: 4.2.2
resolution: "@vue/apollo-ssr@npm:4.2.2"
dependencies:
serialize-javascript: "npm:^6.0.1"
checksum: 10/e9a82bc27ad5ea464f0b9fb30edc7bae03b399bd3c7c8192652a00df5a0f77bf306a911ccf00e060aa72b7bd395eb5cc5abb35c60132f64387ff5ed5526e47e2
checksum: 10/49a8d019b590f90d954db3fcf3e7501deb978737a1ef9bcca4f05e7509242948c007d1e58f9137a5d89eae643f084cae2fe465a23a7bc3caddd3fa20b8d35c6b
languageName: node
linkType: hard