From cd8cd4921630b93602d25fa3be8a040e9b25d67f Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Fri, 2 Jan 2026 17:04:20 +0100 Subject: [PATCH 1/5] feat: prerender hint based on ssrContext access --- src/module.ts | 6 +++- src/runtime/prerender/nitro.plugin.ts | 20 ++++++++++++ src/runtime/prerender/plugin.server.ts | 43 ++++++++++++++++++++++++++ src/runtime/prerender/utils.ts | 13 ++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/runtime/prerender/nitro.plugin.ts create mode 100644 src/runtime/prerender/plugin.server.ts create mode 100644 src/runtime/prerender/utils.ts diff --git a/src/module.ts b/src/module.ts index 0cbb2f2..4c022ca 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addServerHandler } from '@nuxt/kit' +import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addServerHandler, addImports, addImportsSources } from '@nuxt/kit' import { HYDRATION_ROUTE, HYDRATION_SSE_ROUTE } from './runtime/hydration/utils' import { setupDevToolsUI } from './devtools' import { InjectHydrationPlugin } from './plugins/hydration' @@ -53,6 +53,10 @@ export default defineNuxtModule({ addPlugin(resolver.resolve('./runtime/third-party-scripts/plugin.client')) addServerPlugin(resolver.resolve('./runtime/third-party-scripts/nitro.plugin')) + // prerender + addServerPlugin(resolver.resolve('./runtime/prerender/nitro.plugin')) + addPlugin(resolver.resolve('./runtime/prerender/plugin.server')) + nuxt.hook('prepare:types', ({ references }) => { references.push({ types: resolver.resolve('./runtime/types.d.ts'), diff --git a/src/runtime/prerender/nitro.plugin.ts b/src/runtime/prerender/nitro.plugin.ts new file mode 100644 index 0000000..d1cb0af --- /dev/null +++ b/src/runtime/prerender/nitro.plugin.ts @@ -0,0 +1,20 @@ +import type { NitroAppPlugin } from 'nitropack' + +export default function nitroHintsPlugin(nitroApp) { + nitroApp.hooks.hook('render:before', ({ event }) => { + event.context.shouldPrerender = true + }) + + nitroApp.hooks.hook('render:html', (htmlContext, { event }) => { + if (event.context.shouldPrerender === false) { + htmlContext.bodyAppend.push( + ``, + ) + } + else { + htmlContext.bodyAppend.push( + ``, + ) + } + }) +} diff --git a/src/runtime/prerender/plugin.server.ts b/src/runtime/prerender/plugin.server.ts new file mode 100644 index 0000000..7eada63 --- /dev/null +++ b/src/runtime/prerender/plugin.server.ts @@ -0,0 +1,43 @@ +import { defineNuxtPlugin } from '#imports' +import { getStackTraceLines } from './utils' + +export default defineNuxtPlugin({ + name: 'hints:prerender-detection', + setup(nuxtApp) { + const event = nuxtApp.ssrContext!.event + const originalSsrContext = nuxtApp.ssrContext! + + let watching = true + // Access to any property on ssrContext will mark the page as non-prerenderable + nuxtApp.ssrContext = new Proxy(originalSsrContext, { + get(target, prop, receiver) { + if (watching && isUserLandCode()) { + // Mark as non-prerenderable + // we only want to do this when user-land code is being executed + // to avoid false positives from internal framework code + // it's better to be slightly overzealous here than miss actual user code + event.context.shouldPrerender = false + } + return Reflect.get(target, prop, receiver) + }, + }) + + nuxtApp.hook('app:rendered', () => { + watching = false + }) + }, + order: -100000, +}) + +/** + * Determine if the current execution context is user-land code + * by analyzing the stack trace. + * Should ignore the first line as the fn call is this function itself. + */ +function isUserLandCode(offset: number = 1): boolean { + const stack = getStackTraceLines() + const lines = stack.slice(2) + const line = lines[offset] + const isUserLand = !line?.includes('node_modules') && !line?.includes('node:internal') + return isUserLand +} diff --git a/src/runtime/prerender/utils.ts b/src/runtime/prerender/utils.ts new file mode 100644 index 0000000..68439e3 --- /dev/null +++ b/src/runtime/prerender/utils.ts @@ -0,0 +1,13 @@ +/** + * Get all stacktrace lines without the current file + * Don't use in build files if we ever goes into build mode nuxt hiints. Thank you. + */ +export function getStackTraceLines(): string[] { + const stackObject: { stack: string } = {} as { stack: string } + Error.captureStackTrace(stackObject) + + return stackObject.stack + .split('\n') + .slice(1) + .map(line => line.trim()) +} From e0ff4e1db59f237923b98b9bdc2d2c6fbdc42fbc Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 29 Jan 2026 22:26:34 +0100 Subject: [PATCH 2/5] fix: move to defineProperty --- playground/pages/prerender/index.vue | 25 +++++++++++++++++++++++++ src/runtime/prerender/nitro.plugin.ts | 2 +- src/runtime/prerender/plugin.server.ts | 13 +++++++------ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 playground/pages/prerender/index.vue diff --git a/playground/pages/prerender/index.vue b/playground/pages/prerender/index.vue new file mode 100644 index 0000000..2465508 --- /dev/null +++ b/playground/pages/prerender/index.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/runtime/prerender/nitro.plugin.ts b/src/runtime/prerender/nitro.plugin.ts index d1cb0af..78708a6 100644 --- a/src/runtime/prerender/nitro.plugin.ts +++ b/src/runtime/prerender/nitro.plugin.ts @@ -1,7 +1,7 @@ import type { NitroAppPlugin } from 'nitropack' export default function nitroHintsPlugin(nitroApp) { - nitroApp.hooks.hook('render:before', ({ event }) => { + nitroApp.hooks.hook('render:before', ({ event }) => { event.context.shouldPrerender = true }) diff --git a/src/runtime/prerender/plugin.server.ts b/src/runtime/prerender/plugin.server.ts index 7eada63..ed444bd 100644 --- a/src/runtime/prerender/plugin.server.ts +++ b/src/runtime/prerender/plugin.server.ts @@ -4,13 +4,14 @@ import { getStackTraceLines } from './utils' export default defineNuxtPlugin({ name: 'hints:prerender-detection', setup(nuxtApp) { - const event = nuxtApp.ssrContext!.event - const originalSsrContext = nuxtApp.ssrContext! + const event = nuxtApp.ssrContext!.event let watching = true + + const ssrContext = nuxtApp.ssrContext // Access to any property on ssrContext will mark the page as non-prerenderable - nuxtApp.ssrContext = new Proxy(originalSsrContext, { - get(target, prop, receiver) { + Object.defineProperty(nuxtApp, 'ssrContext', { + get() { if (watching && isUserLandCode()) { // Mark as non-prerenderable // we only want to do this when user-land code is being executed @@ -18,7 +19,7 @@ export default defineNuxtPlugin({ // it's better to be slightly overzealous here than miss actual user code event.context.shouldPrerender = false } - return Reflect.get(target, prop, receiver) + return ssrContext }, }) @@ -37,7 +38,7 @@ export default defineNuxtPlugin({ function isUserLandCode(offset: number = 1): boolean { const stack = getStackTraceLines() const lines = stack.slice(2) - const line = lines[offset] + const line = lines[offset] const isUserLand = !line?.includes('node_modules') && !line?.includes('node:internal') return isUserLand } From 48c8454971703bae5b62d5e058c0d49f06a1de0c Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 29 Jan 2026 22:42:34 +0100 Subject: [PATCH 3/5] feat: add nuxtApp.ssContext access before import meta server --- src/module.ts | 2 ++ src/plugins/prerender.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/plugins/prerender.ts diff --git a/src/module.ts b/src/module.ts index 4c022ca..5159e67 100644 --- a/src/module.ts +++ b/src/module.ts @@ -2,6 +2,7 @@ import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addCompone import { HYDRATION_ROUTE, HYDRATION_SSE_ROUTE } from './runtime/hydration/utils' import { setupDevToolsUI } from './devtools' import { InjectHydrationPlugin } from './plugins/hydration' +import { serverFlagPrerenderHint } from './plugins/prerender' // Module options TypeScript interface definition export interface ModuleOptions { @@ -56,6 +57,7 @@ export default defineNuxtModule({ // prerender addServerPlugin(resolver.resolve('./runtime/prerender/nitro.plugin')) addPlugin(resolver.resolve('./runtime/prerender/plugin.server')) + addBuildPlugin(serverFlagPrerenderHint) nuxt.hook('prepare:types', ({ references }) => { references.push({ diff --git a/src/plugins/prerender.ts b/src/plugins/prerender.ts new file mode 100644 index 0000000..11566aa --- /dev/null +++ b/src/plugins/prerender.ts @@ -0,0 +1,32 @@ +import MagicString from 'magic-string' +import { createUnplugin } from 'unplugin' + +export const serverFlagPrerenderHint = createUnplugin(() => { + return { + name: 'hints:prerender-server-flag', + + transform: { + filter: { + code: /import\.meta\.server/, + id: { + exclude: /node_modules/, + }, + }, + handler(code, id) { + if (id.includes('node_modules') || !code.includes('import.meta.server')) { + return null + } + const s = new MagicString(code) + + // ssrContext access will trigger prerender flagging + s.replaceAll('import.meta.server', '(__tryUseNuxtApp()?.ssrContext && import.meta.server)') + + s.prepend(`import { tryUseNuxtApp as __tryUseNuxtApp } from '#imports';\n`) + return { + code: s.toString(), + map: s.generateMap({ hires: true }), + } + }, + }, + } +}) From aa182b44a68b8e7911b82fe769f1c9b4270ee7d5 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 29 Jan 2026 22:46:25 +0100 Subject: [PATCH 4/5] chore: lint --- playground/pages/hydration.vue | 5 ++++- src/module.ts | 2 +- src/runtime/prerender/nitro.plugin.ts | 2 +- src/runtime/prerender/plugin.server.ts | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/playground/pages/hydration.vue b/playground/pages/hydration.vue index 2137bc1..c1d521a 100644 --- a/playground/pages/hydration.vue +++ b/playground/pages/hydration.vue @@ -4,7 +4,10 @@ const isServer = import.meta.server ? 1 : 0