Ctrl K
ComunidadGuías…

cache.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type {
  FieldMergeFunction,
  InMemoryCacheConfig,
  Reference,
  StoreObject,
} from '@apollo/client/core';
import { ref } from 'vue';
import { config } from './config';

type CacheEntity = Reference | StoreObject;

const mergeSuspendedUsers: FieldMergeFunction<CacheEntity[]> = (
  existing = [],
  incoming = [],
  { mergeObjects, readField }
) => {
  if (!incoming.length) {
    return incoming;
  }

  return incoming.map((incomingSuspension) => {
    const incomingId = readField('id', incomingSuspension);
    const incomingUsername = readField('username', incomingSuspension);
    const existingSuspension = existing.find((suspension) => {
      const existingId = readField('id', suspension);
      const existingUsername = readField('username', suspension);
      return incomingId
        ? existingId === incomingId
        : existingUsername === incomingUsername;
    });

    return existingSuspension
      ? mergeObjects(existingSuspension, incomingSuspension)
      : incomingSuspension;
  });
};

// Channel.Moderators and Channel.Admins are each written by several queries with
// DIFFERENT sub-selections (e.g. some include `User { username }`, others only
// `displayName`). Without a merge function Apollo replaces the array
// last-writer-wins and logs "Cache data may be lost when replacing the <field>
// field". Because those writers resolve in a different order during SSR vs. client
// hydration, the restored list could differ from the server-rendered one, flipping
// the "Forum Mod"/"Forum Admin" badge and producing a hydration mismatch.
//
// Merge by the entity's keyField so the result is a union that is independent of
// write order and accumulates fields. This is strictly safer than a `[...incoming]`
// replace because it never drops an entry that a partial/filtered write omits.
// IMPORTANT: this must be SELF-CONTAINED — no closure variables, no references
// to other module-level identifiers. @nuxtjs/apollo serializes
// inMemoryCacheOptions (including these merge functions) into its build-time
// runtime config, which DROPS closures. The previous factory form
// (`mergeByKeyField(keyField)`) compiled to a body referencing a now-undefined
// `keyField`, throwing "keyField is not defined" and crashing SSR on every
// /forums/* page (which merge Channel.Moderators / Channel.Admins). Keeping the
// whole implementation inline avoids that.
//
// Works for both Channel.Moderators (ModerationProfile, keyed by displayName)
// and Channel.Admins (User, keyed by username): the key is resolved by trying
// displayName, then username, then id.
const mergeByEntityKey: FieldMergeFunction<CacheEntity[]> = (
  existing = [],
  incoming = [],
  { mergeObjects, readField }
) => {
  const keyOf = (entity: CacheEntity): string | undefined => {
    const displayName = readField('displayName', entity);
    if (typeof displayName === 'string') return displayName;
    const username = readField('username', entity);
    if (typeof username === 'string') return username;
    const id = readField('id', entity);
    return typeof id === 'string' ? id : undefined;
  };

  const merged: CacheEntity[] = existing.slice();
  const indexByKey = new Map<string, number>();
  existing.forEach((entity, index) => {
    const key = keyOf(entity);
    if (key !== undefined) {
      indexByKey.set(key, index);
    }
  });

  incoming.forEach((incomingEntity) => {
    const key = keyOf(incomingEntity);
    const existingIndex = key !== undefined ? indexByKey.get(key) : undefined;
    const existingEntity =
      existingIndex === undefined ? undefined : merged[existingIndex];

    if (existingIndex === undefined || existingEntity === undefined) {
      if (key !== undefined) {
        indexByKey.set(key, merged.length);
      }
      merged.push(incomingEntity);
    } else {
      merged[existingIndex] = mergeObjects(existingEntity, incomingEntity);
    }
  });

  return merged;
};

const mergeModerators = mergeByEntityKey;
const mergeAdmins = mergeByEntityKey;

// NOTE: the session-seeded auth state (isAuthenticatedVar, usernameVar,
// emailVar, profilePicURLVar, modProfileNameVar, notificationCountVar) used to
// live here as module-level refs. Module-level refs are SHARED across every
// request on a server instance, so they leaked one user's auth state into
// another user's SSR render (a privacy bug + hydration mismatch). They now live
// in `composables/useAuthState.ts` as request-scoped `useState` values. Read
// them with `const usernameVar = useUsername()` etc.

export const userDataLoadingVar = ref(false);

export const isLoadingAuthVar = ref(false);
export const setIsLoadingAuth = (status: boolean) => {
  isLoadingAuthVar.value = status;
};

export const sideNavIsOpenVar = ref(false);
export const setSideNavIsOpenVar = (status: boolean) => {
  // Necessary to prevent a bug in which the event list
  // event listeners interfere with navigation to the links
  // in the side nav. This state is used to turn off the
  // event listeners in the event list when the side nav is open.
  sideNavIsOpenVar.value = status;
};

export const enteredDevelopmentEnvironmentVar = ref(
  config.environment === 'development'
);
export const setEnteredDevelopmentEnvironment = (status: boolean) => {
  enteredDevelopmentEnvironmentVar.value = status;
};

export const inMemoryCacheOptions: InMemoryCacheConfig = {
  typePolicies: {
    Tag: {
      merge: true,
      keyFields: ['text'],
    },
    ServerConfig: {
      keyFields: ['serverName'],
    },
    ModerationProfile: {
      keyFields: ['displayName'],
      merge: true,
      fields: {
        ActivityFeed: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        AuthoredIssues: {
          merge: (_existing = [], incoming) => [...incoming],
        },
      },
    },
    Channel: {
      keyFields: ['uniqueName'],
      merge: true,
      fields: {
        Tags: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Admins: {
          merge: mergeAdmins,
        },
        Moderators: {
          merge: mergeModerators,
        },
        SuspendedUsers: {
          merge: mergeSuspendedUsers,
        },
      },
    },
    Discussion: {
      keyFields: ['id'],
      merge: true,
      fields: {
        Tags: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        DiscussionChannels: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Author: {
          merge: true,
        },
        Channel: {
          merge: true,
        },
        Album: {
          merge: true,
        },
      },
    },
    Comment: {
      keyFields: ['id'],
      merge: true,
      fields: {
        CommentAuthor: {
          merge: true,
        },
        UpvotedByUsers: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        FeedbackComments: {
          merge: (_existing = [], incoming) => [...incoming],
        },
      },
    },
    CommentsAggregate: {
      keyFields: false,
    },
    Event: {
      keyFields: ['id'],
      merge: true,
      fields: {
        Tags: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        EventChannels: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Channels: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Poster: {
          merge: true,
        },
      },
    },
    DiscussionChannel: {
      merge: true,
      fields: {
        UpvotedByUsers: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Channel: {
          merge: true,
        },
        Comments: {
          merge: (_existing = [], incoming) => [...incoming],
        },
      },
    },
    User: {
      keyFields: ['username'],
      merge: true,
      fields: {
        Discussions: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Comments: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        Events: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        UpvotedComments: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        UpvotedDiscussions: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        UpvotedEvents: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        ModProfiles: {
          merge: (_existing = [], incoming) => [...incoming],
        },
        ChannelRoles: {
          merge: (_existing = [], incoming) => [...incoming],
        },
      },
    },
    ChannelRole: {
      merge: true,
    },
    Album: {
      keyFields: ['id'],
      merge: true,
      fields: {
        Images: {
          merge: (existing = [], incoming) => {
            // Handle case where incoming is an empty object instead of array
            if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
              const keys = Object.keys(incoming);
              if (keys.length === 0) {
                // Preserve existing images when incoming is empty object
                return existing;
              }
              // Convert object with numeric keys to array
              if (keys.every(k => !isNaN(Number(k)))) {
                return keys.sort((a, b) => Number(a) - Number(b)).map(k => incoming[k]);
              }
            }
            // If incoming is an empty array but we have existing images, preserve them
            // This prevents cache updates from wiping out existing image data
            if (Array.isArray(incoming) && incoming.length === 0 && existing.length > 0) {
              return existing;
            }
            return Array.isArray(incoming) ? [...incoming] : existing;
          },
        },
      },
    },
    Query: {},
  },
};