Skip to content

Conversation

reinaldomendes
Copy link

Description

The original implementation have problems with performance when it rendering many routes.

Replaced @unhead/dom by @unhead/ssr
Used a custom injectInHtml using html5Parser instead of jsdom with is faster
Added worker threads to avoid locking main event loop
Avoid grow the number of tasks in queue when we have many routes
Added teleport support, the original vite-ssg don't writes teleports to the final file causing SSR mismatches
Detect if is in isHydrationMode by quering [data-server-rendered] on client
Creates SSRApp or App accord with environment and hydration

Same changes of PR #457 but updated to current version 0.28.0 and resolved conflicts.

… --skip-build param to reuse last server and client builds(usefull for debugging)
…o work with string, is 7.2 times faster than JSDOM
…ring substitute version but stil faster then JSDOM), some performance optimizations includes only inject html code once instead of using replace and JSDOM
… HydrationMode when data-server-rendered is found on browser
…ions into separated process. Saves memory"

This reverts commit b8a1fcb.
@userquin
Copy link
Member

uhmmmmm, did you tried using hydration: !import.meta.env.DEV in your ViteSSG entry point? I have it working without any issues, will use createSSRApp instead createApp, this is an example I'm using:

// main.ts
export const createApp = ViteSSG(
  App,
  {
    routes: setupLayouts(routes),
    base: import.meta.env.BASE_URL,
    scrollBehavior(to) {
      if (to.hash) {
        return {
          el: decodeURIComponent(to.hash),
          // top: 120,
          behavior: 'smooth',
        }
      }
      else {
        return new Promise((resolve) => {
          setTimeout(() => resolve({ left: 0, top: 0 }), 300)
        })
      }
    },
  },
  (ctx) => {
    const pinia = createPinia()
    ctx.app.use(pinia)
    const modules = import.meta.glob<{
      install: UserModule
    }>('./modules/*.ts', {
      eager: true,
    })
    for (const { install } of Object.values(modules)) {
      install?.(ctx)
    }
    if (!import.meta.env.SSR) {
      ctx.router.beforeEach(async (to, from, next) => {
        const store = useAuthStore(pinia)
        if (!store.ready)
          store.initialize()

        next()
      })
    }
  },
  {
    hydration: !import.meta.env.DEV, // <=== HERE THE HACK
    // transformState: state => state,
    transformState(state) {
      if (import.meta.env.DEV || import.meta.env.SSR) {
        return JSON.stringify({})
      }

      return state
    },
  },
)


type PreloadLinkTransport = Document | { html:string }

export function buildPreloadLinks<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be optional, I'm using this but using HTTP Link header (instead links in the html) for HTTP/2 (or HTTP/3, QUIC) via preload.json generation

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you show an example how you did this prealod.json with HTTP/3, QUIC?

Copy link
Member

@userquin userquin Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot since our backend cannot use http/3 (quic) yet, but it is the same concept using http/2 , you just add the http header link for the corresponding page. Http/3 and quic will require to send an extra early hints header. The main change from original http/2 behavior is about that this http header link is just a hint for the browser. The preload http header can be used by the browser even before start parsing the htnl response

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can see how we prepare netlify _headers to the add corresponding http header link per page at VitePress docs vuejs/vitepress#4814

Copy link
Author

@reinaldomendes reinaldomendes Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read vuejs/vitepress#4814 and understood what this example does.

But the original vite-ssg does the same thing as my pull request.
the export function buildPreloadLinks is a replacement for the JSDOM renderPreloadLinks.

See the original implementation at
https://github.com/antfu-collective/vite-ssg/blob/main/src/node/build.ts:178

// create jsdom from renderedHTML
        const jsdom = new JSDOM(renderedHTML)

        // render current page's preloadLinks
        renderPreloadLinks(jsdom.window.document, ctx.modules || new Set<string>(), ssrManifest)

If the current version are correct the draft version might be too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not the same, previous code is writting the headers in the html, I'm writing those links in the HTTP Link Response header; with previous code, the browser requires to start parsing the html, with the HTTP Link Header, the browser knows what modules will require the incoming page even before receiving the first html byte

Copy link
Author

@reinaldomendes reinaldomendes Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the HTTP Link Header, the browser knows what modules will require the incoming page even before receiving the first html byte

Both original vite-ssg:main and my pull requests writes the Links to html, the ssrHead.headTags will be injected to the <html><head>
The injection of preload links on the tag html > head is made by JSDOM in the original code.

Both original and pull request code have the const html with the preloads tags <link> in their contents.

pull-request

  const preloads:string[] = buildPreloadLinks({ html: transformedIndexHTML }, ctx.modules || new Set<string>(), ssrManifest)
   let ssrHead = {
      headTags: preloads.join("\n"),
      bodyAttrs: '',
      htmlAttrs: '',
      bodyTagsOpen: '',
      bodyTags: '',
    }
    //inject the ssrHead preloadLinks to the html
    const html = await renderHTML({
      rootContainerId,
      indexHTML: transformedIndexHTML,
      appHTML,
      initialState: transformState(initialState),
      ssrHead,
      teleports: ctx.teleports,
    })

Original

       
       // create jsdom from renderedHTML
       const jsdom = new JSDOM(renderedHTML)
       
      // render current page's preloadLinks
       renderPreloadLinks(jsdom.window.document, ctx.modules || new Set<string>(), ssrManifest)

       // render head
       if (head)
         await renderDOMHead(head, { document: jsdom.window.document })
     
       const html = jsdom.serialize()

R = T extends Document ? HTMLLinkElement : string
>(document: T, attrs: Record<string, any>): R|undefined {
if(!('querySelector' in document)){
const regex = new RegExp(`<link[^>]*href\s*=\s*("|')${attrs.href}\\1[^>]*>`,'m')
Copy link
Member

@userquin userquin Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you also need to parse script tags with type=module, the entry point and handle crossorigin attrs, this is what I'm using to generate .vite/preload.json file via ultrahtml library on the finish hook:

.vite/preload.json generation
const data: {
      [id: string]: {
        path: string
        as: 'font' | 'script' | 'style'
        rel: 'preload' | 'modulepreload'
        crossorigin: boolean
      }[]
    } = {}
    for (const file of files) {
      let id = file.replace(/\.html$/, '')
      if (id === 'index') {
        id = '/'
      }
      // data[id] = []
      data[id] = [{
        path: '/fonts/roboto-v20-latin-regular.woff2',
        as: 'font',
        rel: 'preload',
        crossorigin: true,
      }]
      /* data[id] = [{
        path: '/fonts/roboto-v20-latin-regular.woff2',
        as: 'font',
        rel: 'preload',
        crossorigin: true,
      }, {
        path: '/fonts/roboto-v20-latin-500.woff2',
        as: 'font',
        rel: 'preload',
        crossorigin: true,
      }] */
      const preload = data[id]
      const preloads = parse(await fsp.readFile(path.resolve(cwd, file), 'utf8'))
      await walk(preloads, (node) => {
        // extract all scripts type="module"
        if (node.type === ELEMENT_NODE && node.name === 'script') {
          const type = node.attributes.type
          if (type === 'module') {
            const src = node.attributes.src
            if (src) {
              preload.push({
                path: src,
                as: 'script',
                rel: 'modulepreload',
                crossorigin: node.attributes.crossorigin === 'true' || node.attributes.crossorigin === '',
              })
            }
          }
        }
        // extract all links rel="preload" as="styles"
        if (node.type === ELEMENT_NODE && node.name === 'link') {
          const rel = node.attributes.rel
          const as = node.attributes.as
          if (rel === 'preload') {
            if (as === 'style') {
              const href = node.attributes.href
              if (href) {
                preload.push({
                  path: href,
                  as: 'style',
                  rel: 'preload',
                  crossorigin: node.attributes.crossorigin === 'true' || node.attributes.crossorigin === '',
                })
              }
            }
          }
          else if (rel === 'modulepreload') {
            const href = node.attributes.href
            if (href) {
              preload.push({
                path: href,
                as: 'script',
                rel: 'modulepreload',
                crossorigin: node.attributes.crossorigin === 'true' || node.attributes.crossorigin === '',
              })
            }
          }
        }
      })
    }
    const preloadData = Object.entries(data).reduce((acc, [url, hints]) => {
      const links: string[] = []
      for (const link of hints) {
        links.push(`<${link.path[0] === '/' ? link.path : `/${link.path}`}>; rel=${link.rel}; as=${link.as}${link.crossorigin ? `; crossorigin` : ''}`)
      }
      acc[url] = links.join(', ')
      return acc
    }, {} as Record<string, string>)
    await fsp.writeFile(path.join(cwd, '.vite/preload.json'), JSON.stringify(preloadData, null, 2), 'utf8')

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preload.json

I can't found any 'preload.json' in the repository antfu-collective/vite-ssg.

Where the 'preload.json' is consumed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This a custom plugin for a project at work where we use a custom backend. The preload json file is generated via vite-ssg build end hook

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildPreloadLinks does this.

@reinaldomendes
Copy link
Author

uhmmmmm, did you tried using hydration: !import.meta.env.DEV in your ViteSSG entry point? I have it working without any issues, will use createSSRApp instead createApp, this is an example I'm using:

// main.ts
export const createApp = ViteSSG(
  App,
  {
    routes: setupLayouts(routes),
    base: import.meta.env.BASE_URL,
    scrollBehavior(to) {
      if (to.hash) {
        return {
          el: decodeURIComponent(to.hash),
          // top: 120,
          behavior: 'smooth',
        }
      }
      else {
        return new Promise((resolve) => {
          setTimeout(() => resolve({ left: 0, top: 0 }), 300)
        })
      }
    },
  },
  (ctx) => {
    const pinia = createPinia()
    ctx.app.use(pinia)
    const modules = import.meta.glob<{
      install: UserModule
    }>('./modules/*.ts', {
      eager: true,
    })
    for (const { install } of Object.values(modules)) {
      install?.(ctx)
    }
    if (!import.meta.env.SSR) {
      ctx.router.beforeEach(async (to, from, next) => {
        const store = useAuthStore(pinia)
        if (!store.ready)
          store.initialize()

        next()
      })
    }
  },
  {
    hydration: !import.meta.env.DEV, // <=== HERE THE HACK
    // transformState: state => state,
    transformState(state) {
      if (import.meta.env.DEV || import.meta.env.SSR) {
        return JSON.stringify({})
      }

      return state
    },
  },
)

The option hydration is new option from current version, my changes was based on v0.24.2 and I only fixed the merge conflicts to work with current v0.28.0

@reinaldomendes
Copy link
Author

@userquin When I have more time I will check.

I did't have too much experiences with pull requests workflow in open source software.
How can I resolve this 2 workflows?

Thank you.

@userquin
Copy link
Member

The option hydration is new option from current version, my changes was based on v0.24.2 and I only fixed the merge conflicts to work with current v0.28.0

IIRC hydration option there also in old versions, I don't remember when it was added.

@reinaldomendes
Copy link
Author

IIRC hydration option there also in old versions, I don't remember when it was added.

It's present on version v25.2.0, not before that.

0c2d94f

@Yrds
Copy link

Yrds commented Aug 8, 2025

Thanks for this patch. It’s very useful in non-async-safe cases where you need to change a global during (like i18n.global.locale from i18n-vue) on router.beforeEach and avoid conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants