Skip to content

Bug: Events landing page card sorting hydration error

Issue

Occasionally we will run into an issue with the sorting and filtering functions on our Events landing page. It filters out events that are old via endDate, then sorts events by coming soonest to latest.

If there is an old event that has ended and a pipeline has not recently run, the page cannot get pre-rendered to properly re-hydrate the list of events. You will see the old event pop up on page render for a brief second, the cards will flicker, and then you will see the correct order. While the order is correct, all of the links will become off by 1. The first card will have the old/deleted link instead.

From Duo using a scenario from a recent incident:

During SSR/Prerendering: The today date is set when the page is built (likely yesterday or earlier)
During Client Hydration: The today date is recalculated with the current browser time
The Sibos Frankfurt event ended on 2025-10-02, which was yesterday
During prerendering: It was still "future" so it was included
During hydration: It's now "past" so it gets filtered out
The flickering: Vue detects the mismatch and re-renders, but the array indices shift
Wrong links: The Hackathon event (now at index 0) gets the link that was originally for Sibos (which was at index 0 during prerendering)

https://about.gitlab.com/events/

Developer notes

  • components/events/LandingCards.vue
  • Duo:
Root Cause: Date Comparison Logic
The issue is in the filteredEvents computed property in LandingCards.vue:

const today = new Date();
today.setHours(0, 0, 0, 0); // Normalize to midnight for accurate comparison

const filteredEvents = computed(() => {
  return events.value.filter((event) => {
    const eventDate = parseLocalDate(event.endDate ? event.endDate : event.startDate);
    if (eventDate < today) return false; // This line causes the issue
    // ...
  });
});
Duo proposed solution 🧠

<script setup lang="ts">
import type { ParsedContent } from '@nuxt/content/dist/runtime/types';

interface Filter {
  config: {
    id: string;
    name: string;
  };
}

interface Event {
  name?: string;
  type?: string;
  region?: string;
  location?: string;
  industry?: string;
  startDate?: string;
  endDate?: string;
  description?: string;
  eventURL?: string;
}

interface EventFile extends ParsedContent, Event {}

const props = defineProps<{
  title: string;
  filters: Filter[];
  cardButtonText: string;
  zeroStateTitle: string;
  zeroStateDescription: string;
}>();

const { data: eventFiles } = await useAsyncData(
  'events-landing-cards',
  () => queryContent('/shared/en-us/events/landing/cards').where({ _extension: 'yml' }).find(),
  {
    server: true,
  },
);
const events = computed<Event[]>(() => {
  if (!eventFiles.value) return [];
  return eventFiles.value.map((file: ParsedContent) => ({
    name: (file as EventFile).name || '',
    type: (file as EventFile).type || '',
    region: (file as EventFile).region || '',
    location: (file as EventFile).location || '',
    industry: (file as EventFile).industry || '',
    startDate: (file as EventFile).startDate || '',
    endDate: (file as EventFile).endDate || '',
    description: (file as EventFile).description || '',
    eventURL: (file as EventFile).eventURL || '',
  }));
});

const filterIds = props.filters.map((f) => f.config.id);
const selectedFilters = ref<Record<string, string>>(Object.fromEntries(filterIds.map((id) => [id, ''])));

const getFilterOptions = (filter: Filter) => {
  const options = new Set<string>();
  events.value.forEach((event) => {
    if (event[filter.config.id as keyof Event]) {
      options.add(event[filter.config.id as keyof Event] as string);
    }
  });
  return [
    { label: filter.config.name, config: { id: '' } },
    ...Array.from(options).map((opt) => ({
      label: opt,
      config: { id: opt },
    })),
  ];
};

const parseLocalDate = (dateString: string) => {
  const [year, month, day] = dateString.split('-').map(Number);
  return new Date(year, month - 1, day); // month is 0-indexed in Date constructor
};

const getTodayNormalized = () => {
  const today = new Date();
  today.setHours(0, 0, 0, 0); // Normalize to midnight for accurate comparison
  return today;
};

const filteredEvents = computed(() => {
  const today = getTodayNormalized(); // Calculate today fresh each time
  return events.value.filter((event) => {
    if (!event.startDate) return false; // Skip events without start date
    const eventDate = parseLocalDate(event.endDate || event.startDate);
    if (eventDate < today) return false;
    return Object.entries(selectedFilters.value).every(([key, value]) => {
      return !value || event[key as keyof Event] === value;
    });
  });
});

const sortedFilteredEvents = computed(() => {
  return [...filteredEvents.value].sort((a, b) => {
    const dateA = new Date(a.startDate || '1970-01-01').getTime();
    const dateB = new Date(b.startDate || '1970-01-01').getTime();
    return dateA - dateB;
  });
});

function formatDate(start: string, end?: string) {
  const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' };
  const startDate = parseLocalDate(start);

  if (end && end !== start) {
    const endDate = parseLocalDate(end);
    return `${startDate.toLocaleDateString(undefined, options)} - ${endDate.toLocaleDateString(undefined, options)}`;
  }
  return startDate.toLocaleDateString(undefined, options);
}
</script>

<template>
  <SlpContainer class="slp-mb-64">
    <SlpTypography tag="h2" variant="heading2-bold" class="landing-title">{{ title }}</SlpTypography>
    <div class="filters">
      <SlpDropdown
        v-for="filter in filters"
        :key="filter.config.id"
        v-model="selectedFilters[filter.config.id]"
        :options="getFilterOptions(filter)"
        label-key="label"
        value-key="config.id"
        :name="filter.config.name"
      />
    </div>

    <div v-if="sortedFilteredEvents.length === 0" class="zero-state">
      <SlpTypography tag="h3" variant="heading3-bold" class="slp-mb-8">{{ zeroStateTitle }}</SlpTypography>
      <SlpTypography tag="p" variant="body1">{{ zeroStateDescription }}</SlpTypography>
    </div>

    <div v-else class="event-cards">
      <div
        v-for="event in sortedFilteredEvents"
        :key="`${event.name || 'unnamed'}-${event.startDate || 'no-date'}-${event.eventURL || 'no-url'}`"
        class="event-card"
      >
        <div class="event-card-main">
          <SlpColumn :cols="8">
            <SlpTypography tag="h3" variant="heading4-bold" class="slp-mb-16">{{ event.name }}</SlpTypography>
            <SlpTypography tag="p" variant="body2-bold" class="slp-mb-8">{{ event.type }}</SlpTypography>
            <SlpTypography v-if="event.description" tag="div" variant="body1" class="event-discription">
              <span v-html="$md.render(event.description)"></span>
            </SlpTypography>
          </SlpColumn>
          <SlpColumn :cols="2" class="event-info">
            <SlpTypography tag="p" variant="heading5-bold">
              {{ formatDate(event.startDate, event.endDate) }}
            </SlpTypography>
            <SlpTypography tag="p" variant="body1" class="slp-my-16">
              {{ event.location }},
              {{ event.region }}
            </SlpTypography>
          </SlpColumn>
        </div>
        <SlpButton :href="event.eventURL" variant="primary" :data-ga-name="event.name" data-ga-location="body">
          {{ cardButtonText }}
        </SlpButton>
      </div>
    </div>
  </SlpContainer>
</template>
Edited by Megan Filo