tinacms / tinacms

A fully open-source headless CMS that supports Markdown and Visual Editing
https://tina.io
Apache License 2.0
11.35k stars 574 forks source link

Exclude TinaCMS + dependencies from production build #771

Closed nibtime closed 1 year ago

nibtime commented 4 years ago

Summary

I've been busy with getting a Gatsby site into production and I observed that the app bundle size became unusually large (1.7MB uncompressed), so I installed webpack bundle analyzer and generated a report. And it turned out, that TinaCMS is the main culprit, accounting for around 800-900KB of the size, including dependencies, which are irrelevant to the production build (but are needed for the development server with Tina of course).

I take guidance from the excellent articles The Cost of JavaScript 2018 and The Cost of JavaScript 2019. The overhead that stems from TinaCMS is almost as large as the median uncompressed bundle size. This is a huge obstacle for using Tina with production sites.

Basic example

TinaCMS + its dependencies should not be present in production build. I have no idea though how this can be achieved, since Tina is integrated with code of the site. First thing that comes to mind is Webpack hacking. Maybe you already know that this is a problem and already have an idea how to fix this? If so, this should be part of the docs, since out-of-the-box this blow-up happens.

Here is the report of bundle analyzer of my production build

And this is a regex to filter for dependencies, that are exclusively needed by Tina (@tinacms|prosemirror|codemirror|react-datetime|react-beautiful-dnd|markdown-it|final-form|styled-components|react-dropzone|react-color|tinycolor2|stylis).*.js

Motivation

Obviously faster page loads and less production traffic. Tina should not be that expensive.

ncphillips commented 4 years ago

We're a little ways off from totally taking care of this problem. But I've opened PR #773 to hopefully mitigate future bloat.

I think a major win would be to take better advantage of dynamic imports. Codemirror and Prosemirror are pretty big, if we could split the wysiwyg into it's own chunk that would be quite helpful. I've done this for apps before, but I'm a little nervous about doing it for lbraries.

nibtime commented 4 years ago

Good news, I found a way to hotfix this! App bundle size is now just around 490KB (that's like 1.2MB smaller 😃 ) and Tina is still up and running in development. The commons bundle generated by Gatsby is also 220KB smaller, it now just contains react-dom and schduler. Tina adds additional dependencies there as well, most notably esprima.js and js-yaml.

Here's the new report

And the hotfix of course. It involves 2 parts:

  1. Conditional compilation with Webpack using js-conditional-compile-loader
  2. Only load Tina Gatsby plugins in development mode

Conditional compilation with Webpack

Install js-conditional-compile-loader with yarn add js-conditional-compile-loader -D

Then configure the loader in onCreateWebpackConfig

import { CreateWebpackConfigArgs } from "gatsby"
import path from "path"

const conditionalCompilerRule = [
  {
    test: /\.(ts|tsx)$/,
    include: [path.resolve(`${__dirname}/../../src`)],
    use: [
      "babel-loader?cacheDirectory",
      {
        loader: "js-conditional-compile-loader",
        options: {
          isDebug: process.env.NODE_ENV === "development"
        }
      }
    ]
  }
]
export const onCreateWebpackConfig = ({ actions, getConfig }: CreateWebpackConfigArgs) => {
  const config = getConfig()
  config.module.rules = [...conditionalCompilerRule, ...config.module.rules]
  actions.replaceWebpackConfig(config)
}

Now you can define code, that will just be present in the development build. Make sure that every piece of Tina code is guarded. Looks like this:

UPDATE: I found a better way to approach this, see last section.

/* IFDEBUG */
import { remarkForm } from "gatsby-tinacms-remark"
/* FIDEBUG */
...
let notFoundPage = NotFoundPage
/* IFDEBUG */
notFoundPage = remarkForm(NotFoundPage, { label: "404 Page", fields: basePageFields })
/* FIDEBUG */
// tslint:disable-next-line: export-name
export default notFoundPage
/* IFDEBUG */
import { useLocalRemarkForm } from "gatsby-tinacms-remark"
/* FIDEBUG */
...
/* IFDEBUG */
  useLocalRemarkForm(data.pageData as any, { label: "Membership Page", fields: basePageFields })
  useLocalRemarkForm(data.membershipData as any, {
    label: "Membership Plans",
    fields: membershipPlansFields
  })
/* FIDEBUG */

Load Tina plugins in development only

This is pretty straightforward. Just assign the Tina plugin configuration to a variable and then match on NODE_ENV.

const tinacms = {
  resolve: "gatsby-plugin-tinacms",
  options: {
    sidebar: {
      hidden: process.env.NODE_ENV === "production",
      position: "displace"
    },
    plugins: [
      "gatsby-tinacms-remark",
      "gatsby-tinacms-json",
      {
        resolve: "gatsby-tinacms-git",
        options: {
          /**
           * Must be absolute path to root of workspace / repository
           */
          pathToRepo: WORKSPACE_ROOT,
          /**
           * Must be unprefixed path to Gatsby project, relative to repository root
           */
          pathToContent: "frontend"
        }
      }
    ]
  }
}

export const plugins: Plugins = [
  "gatsby-plugin-typescript",
  "gatsby-plugin-tslint",
  "gatsby-plugin-react-helmet",
  "gatsby-plugin-sass",
  ...(process.env.CI_APPLICATION_ENVIRONMENT === "development_local" ? [bundleAnalyzer] : []),
  ...(process.env.NODE_ENV === "development" ? [tinacms] : []),

Now there's one last thing to do. GraphQL queries using custom fields / fragments required by Tina would fail at this point in production, since we excluded the Tina plugins to provide them.

So need to provide them ourselves. I basically just copy+pasted this from the plugins' code into setFieldsOnGraphQLNodeType of my project.

import { SetFieldsOnGraphQLNodeTypeArgs } from "gatsby"
import { GraphQLString } from "gatsby/graphql"
import slash from "slash"

const setTinaRemarkFields = ({ type }: SetFieldsOnGraphQLNodeTypeArgs) => {
  const pathRoot = slash(process.cwd())

  const hasMarkdown = !!type.nodes.find(node => node.internal.owner === "gatsby-transformer-remark")

  if (hasMarkdown) {
    return {
      rawFrontmatter: {
        type: GraphQLString,
        resolve: source => {
          return JSON.stringify(source.frontmatter)
        }
      },
      fileRelativePath: {
        type: GraphQLString,
        resolve: source => {
          return source.fileAbsolutePath.replace(pathRoot, "")
        }
      }
    }
  }

  // by default return empty object
  return {}
}

const setTinaJsonFields = ({ type, getNode }: SetFieldsOnGraphQLNodeTypeArgs) => {
  const pathRoot = slash(process.cwd())

  const hasJson = !!type.nodes.find(node => node.internal.owner === "gatsby-transformer-json")

  if (!hasJson) {
    return {}
  }

  return {
    rawJson: {
      type: GraphQLString,
      args: {},
      resolve: ({ children, id, internal, parent, fields, ...data }) => {
        return JSON.stringify(data)
      }
    },
    fileRelativePath: {
      type: GraphQLString,
      args: {},
      resolve: ({ parent }) => {
        const p = getNode(parent)

        return p.absolutePath.replace(pathRoot, "")
      }
    }
  }
}

export const setFieldsOnGraphQLNodeType = (args: SetFieldsOnGraphQLNodeTypeArgs): any => {
  if (process.env.NODE_ENV === "production") {
    const jsonFields = setTinaRemarkFields(args)
    const remarkFields = setTinaJsonFields(args)
    return { ...jsonFields, ...remarkFields }
  }
  return {}
}

Now you should have a production bundle without the penalty from Tina + dependencies. 🎉

Hide the ugliness of conditional compilation

I was happy with the result, but I didn't like that the conditional compilation stuff leaked into application code. This can be mitigated by a very simple trick. I created some sort of proxies for the HOCs and hooks from Tina in a local module that have the conditional compilation baked in and then use them as a drop-in replacement. Looks like this:

/* IFDEBUG */
import { remarkForm as tinaRemarkForm } from "gatsby-tinacms-remark"
/* FIDEBUG */

export const remarkForm: typeof tinaRemarkForm = (Component, options) => {
  let returnValue: ReturnType<typeof tinaRemarkForm> = Component
  /* IFDEBUG */
  returnValue = tinaRemarkForm(Component, options)
  /* FIDEBUG */
  return returnValue
}
/* IFDEBUG */
import { useLocalRemarkForm as tinaUseLocalRemarkForm } from "gatsby-tinacms-remark"
/* FIDEBUG */

export const useLocalRemarkForm: typeof tinaUseLocalRemarkForm = (remarkNode, formOverrides) => {
  let returnValue: ReturnType<typeof tinaUseLocalRemarkForm> = [undefined, undefined]
  /* IFDEBUG */
  returnValue = tinaUseLocalRemarkForm(remarkNode, formOverrides)
  /* FIDEBUG */
  return returnValue
}

This way, existing Tina code does not have to be modified, just the import location needs to be changed. After the bundle size problem has been fixed in Tina, the traces of the hack can be removed very easily by removing the stuff from Gatsby APIs and change the proxies so they just reexport from Tina.

export { useLocalRemarkForm } from "gatsby-tinacms-remark"
ncphillips commented 4 years ago

You're a real trooper @nibtime 🚀

Rest assured we want a slightly easier approach then this.

nibtime commented 4 years ago

@ncphillips it's a dirty hack obviously and should not become an official solution 😅. Although it would be possible to refine this and abstract the ugliness of conditional compilation away and reexport HOCs and hooks of Tina with conditional compilation baked-in. The whole thing could then probably also be extracted into a Gatsby theme, including the webpack and plugin configuration part (I've never authored one so far though, just roughly know the concepts).

Of course a solution on library-level, e.g. with dynamic imports would be highly preferable, although I could imagine this might require a lot of effort.

PaulBunker commented 4 years ago

This is a WIN! I'll be implementing this ASAP while we wait for the official solution. Thanks for sharing @nibtime 🍻

DaniTulp commented 4 years ago

Is there any more work being planned to improve this, or a roadmap? I'd be willing to help on this feature! I'm using Next.js right now with previewMode, could something like Dynamic imports be used?

Maybe we could open up a project for this.

ncphillips commented 4 years ago

You're absolutely right, dynamic imports is how this will be done. However, due to constraints with bundlers we're not able to put those dynamic imports inside the libraries defined in tinacms/tinacms. Instead, the packages need to be constructed in such a way that the user can choose to dynamically import them in their site.

This isn't really a cohesive "feature", but just a property that some of these packages should have. The biggest effect will be to move the MarkdownFieldPlugin and HtmlFieldPlugin out of the tinacms package and into the react-tinacms-editor.

This will be a breaking change.

In order to get a wysiwyg in your site you will have to do the following:

import { TinaCMS } from "tinacms"
import { HtmlFieldPlugin } from "react-tinacms-editor"

const cms = new TinaCMS({ ... })

cms.plugins.add(HtmlFieldPlugin)

If you want it to split it out using dynamic imports then you would instead do.

import { TinaCMS } from "tinacms"

const cms = new TinaCMS({ ... })

import("react-tinacms-editor").then(({ HtmlFieldPlugin }) => {
  cms.plugins.add(HtmlFieldPlugin)
})

aside: I'm not sure there's enough here to warrant a new GitHub project at the moment

cc: @jpuri

DaniTulp commented 4 years ago

Yeah sounds great, for now what I'm doing is the following for NextJS, which makes for a considerably smaller build. In pages/_app I'm dynamically importing a wrapper for TinaCMS like so

if (!pageProps.preview) {
    return <Component {...pageProps} />;
  }

  const Tina = dynamic(
    () => import("../components/Tina"),
    {
      ssr: false,
    }
  );
  return (
    <Tina>
      <Component {...pageProps} />
    </Tina>
  );

./components/Tina

import { ReactNode } from "react";
import { Tina as BaseTina, TinaCMS } from "tinacms";

export default function Tina({ children }: { children: ReactNode }) {
  //TODO add options passthrough to cms object
  const cms = new TinaCMS();
  return <BaseTina cms={cms}>{children}</BaseTina>;
}

And then on each page where I would need Tina I'm checking if preview mode is enabled if it is I load the hooks I need for Tina. I am currently using these(100kbish)

import { useForm, usePlugin } from "@tinacms/react-core";

as the standard were still giving me large bundle sizes (400kbish)

 import { useForm, usePlugin } from "tinacms";

But I'm not sure if this is related.

ncphillips commented 4 years ago

This is a fantastic!

You've found exactly why useForm is defined in @tinacms/react-core instead of tinacms.

jpuri commented 4 years ago

Hey @ncphillips : an option could be that we have a wrapper over tinacms where we do these dynamic imports looking at the configurations passed by user. If user tells that they need markdown field we dynamically import editor.

This will keep tina core small.

It definitely does not require new repo.

DaniTulp commented 4 years ago

Sorry for the confusion I didn't mean a new repo but an internal project like the WYSIWYM project https://github.com/tinacms/tinacms/projects. I don't know if it warrants it though.

ncphillips commented 4 years ago

Hey everyone, I've been looking at this today. Checkout the above PR if you want to see what's up.

grncdr commented 4 years ago

warning: long post ahead, feel free to skip to "future improvements" heading to see what I think the API provided by Tina could look like.

I want to share the solutions that I've come up with for a Next.js site I'm working on. My goal was to have as little code as possible included from Tina in my production/static builds, and while my current solution is not yet very ergonomic, I think it could be developed further into something that is simple enough to be used by most/all Tina projects.

@ncphillips:

You're absolutely right, dynamic imports is how this will be done. However, due to constraints with bundlers we're not able to put those dynamic imports inside the libraries defined in tinacms/tinacms. Instead, the packages need to be constructed in such a way that the user can choose to dynamically import them in their site.

This is sort of the approach I followed, but I think that (generally speaking) it's OK to load a lot of heavy dependencies, if you only load them after editing is enabled at run time.

Current solution

Note that I am showing this only because it's currently working for me, I outline at the end of this post how it could be developed into a more friendly/re-usable solution.

The main mechanism is a lib/tina file that uses dynamic imports + a react context to lazy-load Tina at runtime. The rest of my app only accesses tina through the API exposed here.

// lib/tina/index.tsx
import { createContext, createElement, useContext, useState, useEffect } from 'react'

const NoopProvider: React.FC = ({ children }) => children as React.ReactElement

const EnabledContext = createContext(false)

export type { BlockItem, BlockType } from './blockTemplates'

export function useTinaEnabled() {
  return useContext(EnabledContext)
}

export function loadTina() {
  // the DO_NOT_IMPORT file exports a pre-configured "real" provider and re-exports all of the Tina hooks I use elsewhere
  return import('./DO_NOT_IMPORT')
}

export default function DynamicTinaProvider({
  enable = false,
  children,
}: {
  enable: boolean
  children: React.ReactNode
}) {
  const [{ Provider, enabled }, setState] = useState<{ Provider: React.FC<any>; enabled: boolean }>(
    {
      Provider: NoopProvider,
      enabled: false,
    },
  )

  useEffect(() => {
    if (enable) {
      loadTina().then(({ RealTinaProvider }) => {
        setState({ Provider: RealTinaProvider, enabled: true })
      })
    } else {
      setState((prev) => (prev.enabled ? prev : { Provider: NoopProvider, enabled: false }))
    }
  }, [enable])

  return createElement(
    EnabledContext.Provider,
    { value: enabled },
    createElement(Provider, {}, children),
  )
}

I use this provider in _app.tsx like this:


export default function MyApp({ Component, pageProps }) {
  React.useEffect(() => setTinaEnabled(process.env.NODE_ENV !== 'production'), [])

  return (
     <DynamicTinaProvider enable={tinaEnabled}>
        <Component {...pageProps} />
    </DynamicTinaProvider>
  )
}

Finally, my actual pages look like this:

import {useTinaEnabled, loadTina} from 'lib/tina'
import dynamic from 'next/dynamic'

export function SomePage(props) {
  const tinaEnabled = useTinaEnabled()
  if (tinaEnabled) {
    return <EditablePage {...props}/>
  } else {
    return <StaticPage {...props} />
  }
}

const StaticPage = (props) => {
  return <Layout><h1>Hello {props.data.name}</h1>
}

const EditablePage = dynamic(() => {
  return loadTina().then(tina => {
    return function EditablePage(props) {
      const [modifiedData] = tina.useLocalForm(...)
      return <StaticPage {...props} data={modifiedData}/>
    }
  },
  { ssg: false }
}

Future improvements

Obviously that's quite a bit of extra work, but it has the advantage of using only ES7 features, so most of the boilerplate could be abstracted into component decorators that are exported from Tina libraries.

ship an official dynamic provider

As pointed out above, it's probably not a good idea to use dynamic import(...) syntax inside a library, so at some point the user needs to write that, but a modified version of the DynamicTinaProvider I wrote above could accept the loadTina callback as a prop allowing users to write this:

// inside the users application

import { TinaProvider } from 'tinacms/dynamic'

function loadTina() {
  return import('tinacms')
}

export function App() {
  return <TinaProvider loadTina={loadTina}>
   ... my app ...
  </TinaProvider>
}

I think that's a reasonable trade-off in terms of ergonomics for the provider.

ship component decorators

Most of the per-page boilerplate I wrote above could be abstracted into a single component decorator. The strawman API I would put forward is something like editable(editProps, component) that could be used like this:

import {editable} from 'next-tinacms-editable'
import {loadTina} from '../lib/tina'

const Page = editable(loadTina, editProps, function StaticPage(props) {
  return <Layout><h1>Hello {props.data.name}</h1></Layout>
})

// this only gets called if tina is enabled via context
function editProps(pageProps, tina) {
  const [modifiedData] = tina.useForm(...) // set up a form using pageProps.data
  return {...pageProps, data: modifiedData}
}

export default Page

In this case I think it makes sense to export the editable decorator from a framework-specific package, since different frameworks might have different preferences for how to load dynamic components. However, the 'tinacms/dynamic' module could export a version that uses React suspense. Something like this: (warning, not tested at all!)

// in tinacms/dynamic

export function editable(loadTina, editProps, Static) {
  const LazyEditable = React.lazy(() => loadTina().then(tina => {
    return function Editable(props) {
      const modifiedProps = editProps(props, tina)
      return <Static {...modifiedProps}/>
    }
  })

  return function Dynamic(props) {
    const tinaEnabled = useContext(EnabledContext)
    if (tinaEnabled) {
      return <React.Suspense fallback={<Static {...props}/>}>
        <LazyEditable {...props}/>
      </React.Suspense>
    } else {
      return <Static {...props}/>
    }
  }
}

Edit: fixed some formatting

grncdr commented 4 years ago

An update: I also found I wanted something like this for inline editing, where it makes sense to replace the entire element instead of just updating it's props.

export function editableInline<P>(
  StaticComponent: ComponentType<P>,
  EditableComponent: ComponentType<P & { tina: typeof import('./DO_NOT_IMPORT') }>,
) {
  const LazyEditable = dynamic<P>(
    () =>
      loadTina().then((tina) => (props) => createElement(EditableComponent, { ...props, tina })),
    { ssr: false },
  )

  return function DynamicallyEditable(props: P) {
    const state = useContext(TinaStateContext)
    if (state === 'loaded') {
      return createElement(LazyEditable, props)
    } else {
      return createElement(StaticComponent, props)
    }
  }
}

(this is using next/dynamic but could instead use React.Suspense like the previous example code.

ncphillips commented 4 years ago

We also made a specific version for InlineWysiwyg in the tinacms.org website. This version only loads if you're in edit mode, so that JS will never be requested for regular users of your website.

import { useInlineForm } from 'react-tinacms-inline'
import React from 'react'

export function InlineWysiwyg(props: any) {
  const { status } = useInlineForm()
  const [{ InlineWysiwyg }, setEditor] = React.useState<any>({})

  React.useEffect(() => {
    if (!InlineWysiwyg && status === 'active') {
      import('react-tinacms-editor').then(setEditor)
    }
  }, [status])

  if (InlineWysiwyg) {
    return <InlineWysiwyg {...props} />
  }

  return props.children
}

You can just use it as you would use the InlineWysiwyg normally.

sakulstra commented 3 years ago

Hello, I started using tinaCms recently and just realized that it more than doubled my bundle size :sweat_smile:

I was wondering if it would be a way to approach this like some isomorphic libs do:

// index.js
exports.module = process.env.PASSTROUGH ? require('inline-textarea-passtrough') : require('inline-textarea');

where the passtrough is essentially what's currently the else-case of if (cms.enabled) { conditions.

I feel like i'm currently kinda abusing tinaCMS as I use it for inline editing my page, but only in dev mode - on production i don't provide functionality to enter edit mode, so having anything "tina" in the output is especially sad. Would sth like that make sense and be in the scope of tina?

ncphillips commented 3 years ago

I'm taking another look at this today.

The inline editing packages are not the problem here. I ran @next/bundle-analyzer on the tinacms.org website to see what kind of effect Tina was having on that site. Here are the results:

image

The fact is that TinaCMS code is not very big so it's not the problem.

These are the culprits:

Size Package Purpose
575kb `moment Used in the DateFieldPlugin
230kb react-beautiful-dnd Used by Blocks and Group Lists (inline and sidebar)
160kb react-color Used by the ColorFieldPlugin

moment is definitely the easiest package to deal with. You can drastically reduce it's impact by using the moment-locales-webpack-plugin which will strip out a lot of unnecessary information. On tinacms.org adding this plugin reduced moment's impact on bundle size from 575kb to 147kb

To improve things further the DateFieldPlugin and the ColorFieldPlugin may be extracted from tinacms in the same way that the wysiwyg was. By doing this we will make it possible to dynamically load those plugins so moment, react-datetime, and react-color do not impact your initial bundle.

Since react-beautiful-dnd is used by Blocks and Groups both inline and in forms it will be a bit more awkward to deal with. I'll think on this some more.

Still room for improvement but adding moment-locales-webpack-plugin and extracting those two plugins should reduce the TinaCMS bundle size impact by 965kb.

grncdr commented 3 years ago

Hey @ncphillips, while I'm glad to see the work going in to minimizing Tinas the default bundle size, I have a couple of questions:

My perception is that for most static/JAMstack sites, there is a strong distinction between (and distinct trade-offs for) editors and readers. In the case of readers, excluding as much of Tina + deps as possible is very desirable. In the case of editors, they benefit more from caching and it's not really a problem to have some extra dependencies.

I'm (mostly) fine with the workarounds I'm using to lazy-load Tina in my production builds, but I wonder if I'm using Tina in a way that's at odds with the goals of the project.

MANTENN commented 3 years ago

@grncdr I think TinaCMS by default should be lazy loaded when the user begins editing content. Will make the dev UX way better and make the website more better for everyone including those who contribute/edit content and visitors.

DirtyF commented 3 years ago

Tina payload should even be 0 for regular visitors, it's possible to load Tina UI only from a custom route like admin

MANTENN commented 3 years ago

Tina payload should even be 0 for regular visitors, it's possible to load Tina UI only from a custom route like admin

That might seem like a better idea now that I thought about more about the components that interface on the DOM.

Maybe it's possible to LazyLoad the TinaCMS components for a zero-config developer experience by checking whether the user has requested for the component(edit state) and then fetch the JS files and then do a force update of components that have the TinaCMS components implemented.

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

cereallarceny commented 1 year ago

@ncphillips @jeffsee55 I'm not so sure this should have been closed. Here's deployment sizes before Tina:

Screen Shot 2022-09-23 at 3 51 39 PM

And here's my deployment sizes after Tina:

Screen Shot 2022-09-23 at 3 50 56 PM

That's a ~500kb difference for every page that uses the useTina() hook. What gives? Is there a different way I should be going this? I just followed the instructions on the main docs after reading them from beginning to end a good 2 or 3 times. I guess I'm kind of surprised at how stark of a difference I'm seeing now. Is there anything that can be done here? I thought the benefit of using Tina was that we're able to statically build these pages before hand, therefore there shouldn't be any increase (or at least a negligible one at worst).

jeffsee55 commented 1 year ago

@cereallarceny this definitely shouldn't be happening. We'll take a look! Any additional info on your setup would help. Are you certain the increase in bundle is due to useTina or could it be due to something in _app.js because it looks like that one has increased too.

cereallarceny commented 1 year ago

Well, I originally had a <Layout /> component inside my _app.tsx file, which simply renders the appropriate page meta tags, the top navbar, and the footer. However, because these components rely on a content/common/index.json file that's editable in Tina, and thus require the use of getStaticProps, I had to move it out of my _app.tsx and into each of the files in the /pages folder. This is less than ideal, but was the simplest fix I could fashion.

_app.tsx

import type { AppProps } from 'next/app';

import { TinaProvider } from '../.tina';

import '../assets/fonts/recoleta/stylesheet.css';

const Raise: React.FC<AppProps> = ({ Component, pageProps }) => {
  if (pageProps?.statusCode === 404 || pageProps?.statusCode === 500) {
    // @ts-ignore
    return <Component {...pageProps} />;
  }

  return (
    <TinaProvider>
      <Component {...pageProps} />
    </TinaProvider>
  );
};

export default Raise;

And here's one of the pages, but they all basically look the same:

pages/index.tsx

import React from 'react';
import { useTina } from 'tinacms/dist/edit-state';

import type { NextPage } from 'next';

import Components from '../components/pages/index';
import Layout from '../components/Layout';
import { client } from '../.tina';

import type { TinaPageGetStaticProps, PageHomepage } from '../.tina';

const Homepage: NextPage<TinaPageGetStaticProps<PageHomepage>> = ({
  common,
  page,
}) => {
  const { data } = useTina(page);
  // TODO: Not sure why we have to perform this hack on every page, but we do...
  if (!data?.page || data.page._sys.basename !== 'homepage.json') return null;

  return (
    <Layout {...common.data.common}>
      <Components {...data.page} />
    </Layout>
  );
};

export const getStaticProps = async () => ({
  props: {
    common: await client.queries.common({ relativePath: 'index.json' }),
    page: await client.queries.page({ relativePath: 'homepage.json' }),
  },
});

export default Homepage;
jeffsee55 commented 1 year ago

Can you share the contents of TinaProvider?

cereallarceny commented 1 year ago

Oh, TinaProvider just exports the TinaDynamicProvider. My .tina/index.ts file looks like this:

import type { Common } from './__generated__/types';

export { default as schema, config } from './schema';
export { default as TinaProvider } from './components/TinaDynamicProvider';
export { client } from './__generated__/client';

export type TinaPageGetStaticProps<T extends any> = {
  common: { data: { common: Common }; query: string; variables: object };
  page: { data: { page: T }; query: string; variables: object };
};

export * from './__generated__/types';

TinaDynamicProvider.tsx looks like this (auto-generated/grabbed directly from the docs):

import dynamic from 'next/dynamic';
import { TinaEditProvider } from 'tinacms/dist/edit-state';

const TinaProvider = dynamic(() => import('./TinaProvider'), { ssr: false });

const DynamicTina = ({ children }) => (
  <TinaEditProvider editMode={<TinaProvider>{children}</TinaProvider>}>
    {children}
  </TinaEditProvider>
);

export default DynamicTina;
jeffsee55 commented 1 year ago

I've confirmed this is not an issue in our starter

Page                                       Size     First Load JS
┌   /_app                                  0 B            87.3 kB
├ ● /[filename] (759 ms)                   4.06 kB         118 kB
├   ├ /about (420 ms)
├   └ /home (339 ms)
├ ○ /404                                   2.51 kB         117 kB
├ ○ /admin (521 ms)                        517 kB          604 kB
├ ● /post/[filename] (873 ms)              7.98 kB         122 kB
├   ├ /post/anotherPost (450 ms)
├   └ /post/voteForPedro (423 ms)
└ ● /posts (425 ms)                        1.5 kB          116 kB
+ First Load JS shared by all              87.3 kB
  ├ chunks/framework-7f78491ac389bdeb.js   46.5 kB
  ├ chunks/main-922238a218db66d1.js        34.8 kB
  ├ chunks/pages/_app-4effbb51b7a86029.js  3.75 kB
  ├ chunks/webpack-fe7918c2447839a4.js     2.27 kB
  └ css/23fa6976e7f42ea7.css               7.73 kB

○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

One thing you can try to confirm this with is remove Tina stuff from the _app.js, you'll want to also remove calls to useTina but things should work exactly the same without Tina.

cereallarceny commented 1 year ago

Hey @jeffsee55 , I looked into what you said and realized the problem immediately. I was exporting a dynamically imported component. The _app.tsx is using that "re-exported" component <TinaProvider />. Funny enough, and I've never run into this in my life, doing this re-exporting essentially erased the dynamic import nature of that original <TinaProvider /> component. Once I just imported it directly, like such:

import type { AppProps } from 'next/app';

// This is the important line (compare to what I posted above)
import TinaProvider from '../.tina/components/TinaDynamicProvider';

import '../assets/fonts/recoleta/stylesheet.css';

const Raise: React.FC<AppProps> = ({ Component, pageProps }) => {
  if (pageProps?.statusCode === 404 || pageProps?.statusCode === 500) {
    // @ts-ignore
    return <Component {...pageProps} />;
  }

  return (
    <TinaProvider>
      <Component {...pageProps} />
    </TinaProvider>
  );
};

export default Raise;

... I see the following change in build sizes:

Screen Shot 2022-09-23 at 5 18 52 PM

I'd call that fairly significant and these result bring me back to my pre-Tina build sizes. Now I just need to figure out why my build is so large without Tina! 😆

We can probably close this issue at this point.

jeffsee55 commented 1 year ago

Ah wow! That's very strange. I stared at your example code for a while and it seemed fine 😆