ionic-team / ionic-framework

A powerful cross-platform UI toolkit for building native-quality iOS, Android, and Progressive Web Apps with HTML, CSS, and JavaScript.
https://ionicframework.com
MIT License
51.05k stars 13.51k forks source link

bug: Ionic component styles do not apply in Next.js application #25100

Open MarianDabrowski opened 2 years ago

MarianDabrowski commented 2 years ago

Prerequisites

Ionic Framework Version

Current Behavior

SSR/SSG of Nextjs do not work with ionic/react: "^6.0.0". The version worked well with ionic/react 5. I have reached out to ionic community in discord and was suggested creating the issue. The styles do not apply the behaviour is odd when we use SSR or SSG. It works well on client side though.

Expected Behavior

I expect the code to work similar as it worked on version 5.9.3. All the styles do apply there.

Steps to Reproduce

  1. git clone https://github.com/MarianDabrowski/next-app
  2. cd next-app
  3. npm i
  4. npm run dev

Code Reproduction URL

https://github.com/MarianDabrowski/next-app

Ionic Info

[WARN] You are not in an Ionic project directory. Project context may be missing.

Ionic:

Ionic CLI : 6.19.0

Utility:

cordova-res : 0.15.4 native-run : not installed globally

System:

NodeJS : v17.3.1 npm : 8.5.3 OS : macOS Monterey

Additional Information

Based on the discussion with @sean-perkins I believe Sean is right saying: "...custom elements should workin SSR/SSG, so the fact that it isn't, is a little concerning. The root issue is likely Stencil, if the styles are not being applied, but I would recommend creating a ticket in ionic-framework, since the UI kit should support Next.js."

_app.tsx is rendered before each page render As i understand setupIonicReact shall be called at the initial stage.

Altering pages/index.tsx to

const Home: NextPage = () => {
  const [component, setComponent] = useState(<div>I Am a placeholder</div>);

  useEffect(() => {
    setTimeout(() => {
      setComponent(<App />);
    }, 3000);
  });
  return component;
};

makes the code work properly

sean-perkins commented 2 years ago

Context from my discovery & our discord conversation:

The web components are being defined/registered/detected correctly when served through Next.js. The style tag for each web component is not being rendered though. There is an inline comment where the style blocks should be. This results in all the components being in a broken/styleless state.

The fact that it worked < v6, suggests it is an issue with the custom elements build.

I've confirmed that nothing in the Next.js project configuration points at an issue with the reproduction app, so my thoughts are that:

  1. Next.js is somehow stripping out those styles
  2. Stencil is unable to apply the styles in the right execution context with Next.js.
MarianDabrowski commented 2 years ago

Hi there! Any updates on this topic?

liamdebeasi commented 2 years ago

This is also happening with Ionic Core loaded from a CDN, so possibly related: https://github.com/ionic-team/ionic-framework/issues/25398

sean-perkins commented 2 years ago

Upon further discovery, this only effects components that have multiple stylesheets.

For instance:

<IonText color="danger">Hello world</IonText>

Renders correctly, since it only has one stylesheet, regardless of the mode.

Components that have stylesheets per-mode, seem to not create the constructed stylesheet correctly. I believe the source of this issue likely resides from Stencil's internals.

Edit:

Also confirmed that with a brand new Stencil component library making use of multiple stylesheets, it is not a problem within Next.js. This appears to be isolated to Ionic Framework.

liamdebeasi commented 2 years ago

Does the Stencil component library use the custom elements bundle or lazy loaded bundle?

sean-perkins commented 2 years ago

Custom elements bundle, doing something similar to:

const App: React.FC = () => {
  useEffect(() => {
    import("stencil-nextjs/dist/components/my-component.js").then((x) => {
      x.defineCustomElement();
    });
  }, []);

  return (
    <my-component first="Sean" last="Perkins" mode="md"></my-component>
  );
};

Compared with setting the mode explicitly for our components, but still does not render with the stylesheet. I am curious if our logic to return early if window is not defined in initialize is related: https://github.com/ionic-team/ionic-framework/blob/main/core/src/global/ionic-global.ts#L17-L19

MarianDabrowski commented 2 years ago

I love to see some updates on this topic! Please tell me, if there are any tasks that I can help you with. @sean-perkins awesome analysis on your side!

liamdebeasi commented 2 years ago

@sean-perkins getIonMode should be falling back to defaultMode: https://github.com/ionic-team/ionic-framework/blob/main/core/src/global/ionic-global.ts#L13, but I see that it is not set until here: https://github.com/ionic-team/ionic-framework/blob/main/core/src/global/ionic-global.ts#L60-L64

I wonder if we could make it so that the in-memory config is setup even if window is not defined? That would at least let us have the mode get configured properly even if configFromSession and configFromURL won't work in SSR mode.

liamdebeasi commented 2 years ago

Something like this:

const configObj = {
  persistConfig: false,
  ...userConfig,
};

config.reset(configObj);
if (config.getBoolean('persistConfig')) {
  saveConfig(win, configObj);
}

// we can't call || isPlatform(win, 'ios') ? 'ios' : 'md' here because `window`
// is not necessarily available. Maybe we can re-update `defaultMode` in the
// if block below?
defaultMode = config.get(
  'mode',
   doc.documentElement.getAttribute('mode')
);

if (typeof window !== 'undefined') {
  // everything else
}
MarianDabrowski commented 2 years ago

@liamdebeasi as I understand, the consequences of this issue is that no meter the framework we cannot use SSR/SSG with ionic 6. Is it so?

ihormak commented 2 years ago

Have a similar issue. Any progress? @liamdebeasi @sean-perkins

MarianDabrowski commented 2 years ago

Something like this:

const configObj = {
  persistConfig: false,
  ...userConfig,
};

config.reset(configObj);
if (config.getBoolean('persistConfig')) {
  saveConfig(win, configObj);
}

// we can't call || isPlatform(win, 'ios') ? 'ios' : 'md' here because `window`
// is not necessarily available. Maybe we can re-update `defaultMode` in the
// if block below?
defaultMode = config.get(
  'mode',
   doc.documentElement.getAttribute('mode')
);

if (typeof window !== 'undefined') {
  // everything else
}

@liamdebeasi I wonder if you can elaborate on this, I tried to build it locally, but I failed to understand what changes are necessary.

liamdebeasi commented 2 years ago

The main issue is that when setting up the config we return early if no window is available: https://github.com/ionic-team/ionic-framework/blob/main/core/src/global/ionic-global.ts#L18

This means that the in-memory config object is never created. As a result, certain things like the default mode are not defined. The in-memory config should be usable even if window is not defined, so we likely need to re-arrange the initialize function to not return early. Instead, we likely need to wrap the window-specific parts in if/else blocks so that the in-memory config is still created even in SSR/SSG environments.

MarianDabrowski commented 2 years ago

@liamdebeasi thank you. for the response it has helped me much. I have played with the order and guess the one below may solve the issue.

export const initialize = (userConfig: IonicConfig = {}) => {
  if (typeof (window as any) === 'undefined') {
    Context.config = config;
    const Ionic = {} as any;

    const platformHelpers: any = {};
    if (userConfig._ael) {
      platformHelpers.ael = userConfig._ael;
    }
    if (userConfig._rel) {
      platformHelpers.rel = userConfig._rel;
    }
    if (userConfig._ce) {
      platformHelpers.ce = userConfig._ce;
    }
    setPlatformHelpers(platformHelpers);

    const configObj = {
      persistConfig: false,
      ...userConfig,
    };

    config.reset(configObj);

    Ionic.config = config;
    Ionic.mode = defaultMode = config.get(
      'mode',
      'md' // I assume 'md' is default
    );

    config.set('mode', defaultMode);

    if (config.getBoolean('_testing')) {
      config.set('animated', false);
    }
    setMode((_: any) => defaultMode);
  }
  else {
    const doc = window.document;
    const win = window;
    Context.config = config;
    const Ionic = ((win as any).Ionic = (win as any).Ionic || {});

    const platformHelpers: any = {};
    if (userConfig._ael) {
      platformHelpers.ael = userConfig._ael;
    }
    if (userConfig._rel) {
      platformHelpers.rel = userConfig._rel;
    }
    if (userConfig._ce) {
      platformHelpers.ce = userConfig._ce;
    }
    setPlatformHelpers(platformHelpers);

    // create the Ionic.config from raw config object (if it exists)
    // and convert Ionic.config into a ConfigApi that has a get() fn
    const configObj = {
      ...configFromSession(win),
      persistConfig: false,
      ...Ionic.config,
      ...configFromURL(win),
      ...userConfig,
    };

    config.reset(configObj);
    if (config.getBoolean('persistConfig')) {
      saveConfig(win, configObj);
    }

    // Setup platforms
    setupPlatforms(win);

    // first see if the mode was set as an attribute on <html>
    // which could have been set by the user, or by pre-rendering
    // otherwise get the mode via config settings, and fallback to md
    Ionic.config = config;
    Ionic.mode = defaultMode = config.get(
      'mode',
      doc.documentElement.getAttribute('mode') || (isPlatform(win, 'ios') ? 'ios' : 'md')
    );
    config.set('mode', defaultMode);
    doc.documentElement.setAttribute('mode', defaultMode);
    doc.documentElement.classList.add(defaultMode);

    if (config.getBoolean('_testing')) {
      config.set('animated', false);
    }

    const isIonicElement = (elm: any) => elm.tagName?.startsWith('ION-');

    const isAllowedIonicModeValue = (elmMode: string) => ['ios', 'md'].includes(elmMode);

    setMode((elm: any) => {
      while (elm) {
        const elmMode = (elm as any).mode || elm.getAttribute('mode');
        if (elmMode) {
          if (isAllowedIonicModeValue(elmMode)) {
            return elmMode;
          } else if (isIonicElement(elm)) {
            console.warn('Invalid ionic mode: "' + elmMode + '", expected: "ios" or "md"');
          }
        }
        elm = elm.parentElement;
      }
      return defaultMode;
    });
  }
};

Are there any docs that describe how to build ionic so that I can check if it works with nextjs SSR/SSG?

sean-perkins commented 2 years ago

@MarianDabrowski our contributing guide includes steps for building both core and the individual framework packages: https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#building-changes

You will want to:

  1. Run npm run build from core/
  2. Run npm run build from /packages/react-router (you may need to install dependencies in this directory if it is your first time)
  3. Run npm run build from /packages/react (you may need to install dependencies in this directory if it is your first time)
  4. Run npm pack from /packages/react
  5. Copy the generated .tgz into your NextJS application folder
  6. npm install the path of the .tgz
  7. Re-run your NextJS application
MarianDabrowski commented 2 years ago

@sean-perkins thank you! I will do it!

MarianDabrowski commented 2 years ago

I have just tested the proposed idea and unfortunately the app does not work as expected. I have followed the steps to get the package (during npm i have used --legacy-peer-deps, as there was some conflict with tslint), the experiment result is here: https://github.com/MarianDabrowski/next-app/tree/experiment/changes-inonic-core-initialize

liamdebeasi commented 2 years ago

The problem appears to be that the style variable in Stencil's initializeComponent function is undefined: https://github.com/ionic-team/stencil/blob/63dbb47a14cc840c8d37f1bf7ce315d306194788/src/runtime/initialize-component.ts#L95

Taking ion-button as an example, the stylesheet strings do appear to be loaded. The computeMode function in that initializeComponent function returns undefined, so Stencil does not grab the correct stylesheet string. Understanding why computeMode cannot determine the mode is likely the key to fixing this issue.


Editing the file and then saving it "fixes" the issue as the styles get loaded.

liamdebeasi commented 2 years ago

There are a couple problems here:

  1. The initialize function returns early, causing the config + mode to never be set up. We return early if the window is not defined: https://github.com/ionic-team/ionic-framework/blob/1b1b1a3800c4d044b4a3e7418f534e9271770ec6/core/src/global/ionic-global.ts#L17

While this prevents the config from being setup, it also prevents a critical piece of Stencil's "mode" infrastructure from being initialized: https://github.com/ionic-team/ionic-framework/blob/1b1b1a3800c4d044b4a3e7418f534e9271770ec6/core/src/global/ionic-global.ts#L76

This function is what automatically determines which mode to apply to the components. Since no mode could be determined, no stylesheets are loaded for components with mode-specific stylesheets. (See https://github.com/ionic-team/ionic-framework/issues/25100#issuecomment-1172553894)

  1. setMode is called too late in a Next.js app. Even if we reconfigured the initialize function to move setMode all the way to the top, that would not completely fix the issue. The initialize function appears to be getting called after Stencil tries to apply the stylesheets.

This code reproduces the issue in Next.js:

import { setupIonicReact, IonApp, IonButton } from "@ionic/react";

setupIonicReact();

const App: React.FC = () => (
  <IonApp>
    <IonButton color="danger">Cick me</IonButton>
  </IonApp>
);

export default App;

If you were to place the same code in a Create React App/Webpack React app, the styles would get loaded because initialize is called before Stencil tries to apply the stylesheets. This difference needs to be investigated more.

MarianDabrowski commented 2 years ago

Are there any updates?

romfilippini-gp commented 1 year ago

Our team is having the same exact issue, but we're trying to integrate our Stencil component library's React wrapper (reactOutputTarget). So that we can use React-wrapped versions of our components in NextJS.

We've tried several iterations of styled-components setups.

Has anyone found any possible solutions or ideas that we can try?

MarianDabrowski commented 1 year ago

@romfilippini-gp I believe the issue is related to this https://github.com/ionic-team/stencil-ds-output-targets/issues/323

qbx2 commented 1 year ago

Wow, calling setupIonicReact() worked for me.

romfilippini-gp commented 1 year ago

Wow, calling setupIonicReact() worked for me.

@qbx2 Could you please post a sample of how you did it in your project?

qbx2 commented 1 year ago

@romfilippini-gp I was trying to setup nextjs+tailwind+ionic+capacitor from scratch. Without setupIonicReact(), ionic css didn't apply. The example is here: https://github.com/mlynch/nextjs-tailwind-ionic-capacitor-starter/blob/main/components/AppShell.jsx#L14 . Now I have nextjs+tailwind+ionic+capacitor with latest version

gabfiocchi commented 1 year ago

Any update with Ionic 7? Thanks!

elix1er commented 6 months ago

Bump