kamilkisiela / apollo-angular

A fully-featured, production ready caching GraphQL client for Angular and every GraphQL server 🎁
https://apollo-angular.com
MIT License
1.5k stars 308 forks source link

Apollo-Angular providers break Angular Material. #2266

Closed KariNarhi28 closed 2 days ago

KariNarhi28 commented 2 days ago

Describe the bug

I have simple Angular starting template with few Angular Material components and Apollo Angular GraphQL-client.

When I add the Apollo-providers to AppConfig-providers, the Material components break.

They do render, but none of the features they have work.

kuva

Most likely I might have configured the Apollo-factory -function wrong, but the console errors (see other image below) hint that the issue lies in Apollo-Angular or it's dependencies.

To Reproduce

Steps to reproduce the behavior:

app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import {
  provideRouter,
  withEnabledBlockingInitialNavigation,
  withInMemoryScrolling,
} from '@angular/router';

import { routes } from './app.routes';
import {
  BrowserModule,
  provideClientHydration,
} from '@angular/platform-browser';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { apolloProviders } from '@core/apollo-client-provider';

export const appConfig: ApplicationConfig = {
  providers: [
    BrowserModule,
    provideHttpClient(withFetch()),
    provideAnimations(),
    provideAnimationsAsync(),
    ...apolloProviders,
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(
      routes,
      withEnabledBlockingInitialNavigation(),
      withInMemoryScrolling({
        scrollPositionRestoration: 'disabled',
      })
    ),
    provideClientHydration(),
  ],
};

apollo-client-provider.ts:

import { isPlatformBrowser } from '@angular/common';
import { HttpHeaders } from '@angular/common/http';
import {
  ApplicationConfig,
  FactoryProvider,
  PLATFORM_ID,
  TransferState,
  makeStateKey,
} from '@angular/core';
import {
  ApolloClientOptions,
  ApolloLink,
  InMemoryCache,
  NormalizedCacheObject,
} from '@apollo/client/core';
import { APOLLO_OPTIONS, Apollo } from 'apollo-angular';
import { HttpLink, Options } from 'apollo-angular/http';
import { Request } from 'express';
import { environment } from '@environments/environment';
import possibleTypesResult from '@common/possible-types';

/**
 * State key for Apollo cache
 */
const STATE_KEY = makeStateKey<NormalizedCacheObject>('apollo.state');

/**
 * Cache calls this function when the field is about to be written with an incoming value.
 *
 * When write occurs, the field's new value is set to the merge function's return value.
 *
 * @param existing existing value
 * @param incoming incoming value
 * @returns Existing and incoming value merged together.
 */
function mergeFields(existing: any, incoming: any) {
  return { ...existing, ...incoming };
}

/**
 * Cache calls this function when the field is about to be written with an incoming value.
 *
 * When write occurs, the field's new value is set to the merge function's return value.
 *
 * @param _existing existing value (not used)
 * @param incoming incoming value
 * @returns Incoming value (replaces existing value)
 */
function replaceFields(_existing: any, incoming: any) {
  return incoming;
}

/**
 * Trying to debug why sessions won't work in Safari 13.1 but only on the live prod version.
 * @param on Turn on or off logging
 */
function logInterceptorData(on: boolean) {
  localStorage.setItem('_logInterceptorData', on ? 'true' : 'false');
}

// Expose logInterceptorData to the window object for debugging purposes
if (typeof window !== 'undefined') {
  (window as any).logInterceptorData = logInterceptorData;
}

/**
 * Apollo options factory
 * @param httpLink Http link
 * @param platformId Platform ID
 * @param transferState Transfer state
 * @param _req Request object (optional)
 * @returns Apollo client options
 */
function apolloOptionsFactory(
  httpLink: HttpLink,
  platformId: object,
  transferState: TransferState,
  _req?: Request
): ApolloClientOptions<any> {
  /**
   * Auth token key
   */
  const AUTH_TOKEN_KEY = 'auth_token';

  /**
   * Apollo cache
   */
  const apolloCache = new InMemoryCache({
    // Possible types
    possibleTypes: possibleTypesResult.possibleTypes,

    // Type policies
    typePolicies: {
      Query: {
        fields: {
          eligibleShippingMethods: {
            merge: replaceFields,
          },
        },
      },
      Product: {
        fields: {
          customFields: {
            merge: mergeFields,
          },
        },
      },
      Collection: {
        fields: {
          customFields: {
            merge: mergeFields,
          },
        },
      },
      Order: {
        fields: {
          lines: {
            merge: replaceFields,
          },
          shippingLines: {
            merge: replaceFields,
          },
          discounts: {
            merge: replaceFields,
          },
          shippingAddress: {
            merge: replaceFields,
          },
          billingAddress: {
            merge: replaceFields,
          },
        },
      },
      Customer: {
        fields: {
          addresses: {
            merge: replaceFields,
          },
          customFields: {
            merge: mergeFields,
          },
        },
      },
    },
  });

  // Get the API host, port, and path from the environment
  const { apiHost, apiPort, shopApiPath } = environment;

  // Define the GraphQL API URI
  const uri = `${apiHost}:${apiPort}/${shopApiPath}`;

  /**
   * HTTP options
   */
  const httpOptions: Options = {
    uri,
    withCredentials: false,
  };

  /**
   * HTTP link handler
   */
  const httpLinkHandler = httpLink.create(httpOptions);

  /**
   * ApolloLink which sets the auth token in localStorage
   */
  const afterware = new ApolloLink((operation, forward) => {
    return forward(operation).map((response) => {
      // Apollo operation context
      const context = operation.getContext();
      // Auth header
      const authHeader: string =
        context['response'].headers.get('vendure-auth-token');

      // Check if the auth header is present and the platform is the browser
      if (authHeader && isPlatformBrowser(platformId)) {
        // If the auth token has been returned by the Vendure
        // server, we store it in localStorage
        localStorage.setItem(AUTH_TOKEN_KEY, authHeader);
      }
      return response;
    });
  });

  /**
   * Middleware which sets the auth token in the request headers
   */
  const middleware = new ApolloLink((operation, forward) => {
    // Check if the platform is the browser
    if (isPlatformBrowser(platformId)) {
      // Set the operation context
      operation.setContext({
        // Set the auth token in the request headers
        headers: new HttpHeaders().set(
          'Authorization',
          `Bearer ${localStorage.getItem(AUTH_TOKEN_KEY)}`
        ),
      });
    }
    return forward(operation);
  });

  /**
   * Whether the platform is the browser
   */
  const isBrowser = transferState.hasKey(STATE_KEY);

  // Check if the platform is the browser
  if (isBrowser) {
    // Get the state from the transfer state
    const state = transferState.get(STATE_KEY, {});

    // Restore the Apollo cache
    apolloCache.restore(state);
  } else {
    // Serialize the Apollo cache
    transferState.onSerialize(STATE_KEY, () => {
      return apolloCache.extract();
    });

    // Reset apolloCache after extraction to avoid sharing between requests
    apolloCache.reset();
  }

  // Return Apollo client options
  return {
    cache: apolloCache,
    ssrMode: true,
    ssrForceFetchDelay: 500,
    link: ApolloLink.from([middleware, afterware, httpLinkHandler]),
  };
}

/**
 * Apollo client provider
 */
const APOLLO_CLIENT_PROVIDER: FactoryProvider = {
  provide: APOLLO_OPTIONS,
  useFactory: apolloOptionsFactory,
  deps: [HttpLink, PLATFORM_ID, TransferState],
};

/**
 * Apollo providers
 */
export const apolloProviders: ApplicationConfig['providers'] = [
  Apollo,
  APOLLO_CLIENT_PROVIDER,
];

app.component.html:

<mat-form-field>
  <mat-label>Input</mat-label>
  <input matInput />
</mat-form-field>
<mat-form-field>
  <mat-label>Select</mat-label>
  <mat-select>
    <mat-option value="one">First option</mat-option>
    <mat-option value="two">Second option</mat-option>
  </mat-select>
</mat-form-field>
<mat-form-field>
  <mat-label>Textarea</mat-label>
  <textarea matInput></textarea>
</mat-form-field>

app.component.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { MaterialModule } from './material.module';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, MaterialModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent { }

material.module.ts

import { NgModule } from '@angular/core';
import {
  MatCommonModule,
  MatLineModule,
  MatNativeDateModule,
  MatPseudoCheckboxModule,
  MatRippleModule,
} from '@angular/material/core';
import { MatOptionModule } from '@angular/material/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSliderModule } from '@angular/material/slider';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSortModule } from '@angular/material/sort';
import { MatStepperModule } from '@angular/material/stepper';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTreeModule } from '@angular/material/tree';
import { A11yModule } from '@angular/cdk/a11y';
import { CdkAccordionModule } from '@angular/cdk/accordion';
import { CdkStepperModule } from '@angular/cdk/stepper';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { CdkMenuModule } from '@angular/cdk/menu';
import { DialogModule } from '@angular/cdk/dialog';
import { CdkTableModule } from '@angular/cdk/table';
import { CdkTreeModule } from '@angular/cdk/tree';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { OverlayModule } from '@angular/cdk/overlay';
import { PortalModule } from '@angular/cdk/portal';
import { ScrollingModule } from '@angular/cdk/scrolling';

/**
 * This module imports and re-exports all Angular Material modules for convenience,
 * so only 1 module import is needed in your feature modules.
 * See https://material.angular.io/guide/getting-started#step-3-import-the-component-modules.
 *
 * To optimize your production builds, you should only import the components used in your app.
 */
@NgModule({
  exports: [
    MatAutocompleteModule,
    MatBadgeModule,
    MatButtonModule,
    MatButtonToggleModule,
    MatCardModule,
    MatCheckboxModule,
    MatChipsModule,
    MatCommonModule,
    MatDatepickerModule,
    MatDialogModule,
    MatDividerModule,
    MatExpansionModule,
    MatFormFieldModule,
    MatGridListModule,
    MatIconModule,
    MatInputModule,
    MatLineModule,
    MatListModule,
    MatMenuModule,
    MatNativeDateModule,
    MatOptionModule,
    MatPaginatorModule,
    MatProgressBarModule,
    MatProgressSpinnerModule,
    MatPseudoCheckboxModule,
    MatRadioModule,
    MatRippleModule,
    MatSelectModule,
    MatSidenavModule,
    MatSlideToggleModule,
    MatSliderModule,
    MatSnackBarModule,
    MatSortModule,
    MatStepperModule,
    MatTableModule,
    MatTabsModule,
    MatToolbarModule,
    MatTooltipModule,
    MatTreeModule,
    A11yModule,
    CdkAccordionModule,
    ClipboardModule,
    CdkMenuModule,
    CdkStepperModule,
    CdkTableModule,
    CdkTreeModule,
    DragDropModule,
    MatBottomSheetModule,
    OverlayModule,
    PortalModule,
    ScrollingModule,
    DialogModule,
  ],
})
export class MaterialModule {}

Expected behavior

Adding the Apollo-providers should not cause issues for non-related packages, such as Angular Material.

Environment:

- @angular/cli@18.0.5
- @angular/core@18.0.4
- @apollo/client@3.10.6
- apollo-angular@7.0.2
- graphql@16.9.0
- typescript@5.4.5

Additional context

I am also getting this kind of error to the console. I am not using these Node-modules it refers to.

kuva
PowerKiKi commented 2 days ago

Create a minimal reproduction case on https://stackblitz.com, and maybe you can get some sort of help. But like you said, it is likely something wrong in your code, not in the libraries.

KariNarhi28 commented 2 days ago

Tried to create the reproduction but Stackblitz is either really slow or not loading for some reason.

Keeps being stuck on "Building....".

However, if Stackblitz works properly for you others, then this example should be able to reproduce the issue.

Even if this cannot be properly reproduced, I would appreciate if people could point out potential errors in the apollo-client-provider.ts -configuration.

Honestly, it is not even my own code, it was copied from the Angular-starter made by folks at Vendure.io.

But since this problem seemed to be related to Apollo Angular, I made this issue here instead of Vendure's Angular-starter repo.

EDIT:

I think I might have found the origin, it is related to how the link-property of the ApolloClientOptions is configured.

This part here: link: ApolloLink.from([middleware, afterware, httpLinkHandler]),

When I replaced that with the example in the docs: link: httpLink.create({ uri, withCredentials: false, })

Then the error disappeared, so the issue must be with the [middleware, afterware, httpLinkHandler] -trio, since link should accept ApolloLink-type.

EDIT_2:

I guess not, did not help when removed the middleware and afterware.

Now I suspect the request inside the deps: deps: [HttpLink, PLATFORM_ID, TransferState, [new Optional(), request]],

After commenting out the request, then the error disappeared again.

I am not an expert of ApolloClientOptions so I don't even know if that [new Optional(), request] -part would do anything in the deps.

PowerKiKi commented 2 days ago

This is not a problem with apollo-angular. You are trying to provide request in a browser environnement where it cannot exist. It can only exist in server environnement.

If the your code comes from Angular-starter, then report an issue over there.

KariNarhi28 commented 2 days ago

Yeah, my bad. Informed the original source of this issue.