From e22419b37956c31765e94c5aee9acbb66fc71f35 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 21:12:18 +1000 Subject: [PATCH] fix(maps): render renderless map components as null Eight map components had a template with only a comment or nothing. The server rendered no node while the client expected a comment, so hydration reported a mismatch. The five Google Maps components had empty templates, which mismatch even in `nuxt dev`. A `render` returning `null` yields a comment on both sides, the shape the MapLibre controls adopted in #910. The new e2e fixture builds with `comments: false`, because the Vitest worker loads Vue's development compiler, which keeps template comments. --- package.json | 2 +- .../GoogleMaps/ScriptGoogleMapsCircle.vue | 8 ++-- .../GoogleMaps/ScriptGoogleMapsGeoJson.vue | 8 ++-- .../GoogleMaps/ScriptGoogleMapsPolygon.vue | 8 ++-- .../GoogleMaps/ScriptGoogleMapsPolyline.vue | 8 ++-- .../GoogleMaps/ScriptGoogleMapsRectangle.vue | 8 ++-- .../Leaflet/ScriptLeafletGeoJson.vue | 9 ++-- .../Leaflet/ScriptLeafletTileLayer.vue | 9 ++-- .../MapLibre/ScriptMapLibreGeoJson.vue | 9 ++-- test/e2e/map-hydration.test.ts | 45 +++++++++++++++++++ test/fixtures/map-hydration/app.vue | 3 ++ test/fixtures/map-hydration/nuxt.config.ts | 17 +++++++ test/fixtures/map-hydration/package.json | 1 + .../map-hydration/pages/google-maps.vue | 18 ++++++++ test/fixtures/map-hydration/pages/leaflet.vue | 20 +++++++++ .../fixtures/map-hydration/pages/maplibre.vue | 29 ++++++++++++ test/fixtures/map-hydration/tsconfig.json | 3 ++ 17 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 test/e2e/map-hydration.test.ts create mode 100644 test/fixtures/map-hydration/app.vue create mode 100644 test/fixtures/map-hydration/nuxt.config.ts create mode 100644 test/fixtures/map-hydration/package.json create mode 100644 test/fixtures/map-hydration/pages/google-maps.vue create mode 100644 test/fixtures/map-hydration/pages/leaflet.vue create mode 100644 test/fixtures/map-hydration/pages/maplibre.vue create mode 100644 test/fixtures/map-hydration/tsconfig.json diff --git a/package.json b/package.json index fa75c649a..722ebb844 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve && nuxt prepare test/fixtures/maplibre", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve && nuxt prepare test/fixtures/maplibre && nuxt prepare test/fixtures/map-hydration", "typecheck": "pnpm --filter @nuxt/scripts-cli typecheck && nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue index a7506865d..2a1469439 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue @@ -2,6 +2,11 @@ import { watch } from 'vue' import { bindGoogleMapsEvents, useGoogleMapsResource } from './useGoogleMapsResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** * Configuration options for the circle overlay. @@ -112,6 +117,3 @@ watch(() => props.options, (options) => { } }, { deep: true }) - - diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsGeoJson.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsGeoJson.vue index 652141c4f..c9461edc2 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsGeoJson.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsGeoJson.vue @@ -2,6 +2,11 @@ import { watch } from 'vue' import { bindGoogleMapsEvents, useGoogleMapsResource } from './useGoogleMapsResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** * The GeoJSON source. Can be a URL string or a GeoJSON object. @@ -132,6 +137,3 @@ watch(() => props.style, (style) => { dataLayer.value.setStyle(style ?? {}) }, { deep: true }) - - diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue index 62975b573..da63e18e8 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue @@ -2,6 +2,11 @@ import { watch } from 'vue' import { bindGoogleMapsEvents, useGoogleMapsResource } from './useGoogleMapsResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** * Configuration options for the polygon overlay. @@ -95,6 +100,3 @@ watch(() => props.options, (options) => { } }, { deep: true }) - - diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue index c3d4797fc..9431f899a 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue @@ -2,6 +2,11 @@ import { watch } from 'vue' import { bindGoogleMapsEvents, useGoogleMapsResource } from './useGoogleMapsResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** * Configuration options for the polyline overlay. @@ -95,6 +100,3 @@ watch(() => props.options, (options) => { } }, { deep: true }) - - diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue index ed2b95bac..a5240ff5d 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue @@ -2,6 +2,11 @@ import { watch } from 'vue' import { bindGoogleMapsEvents, useGoogleMapsResource } from './useGoogleMapsResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** * Configuration options for the rectangle overlay. @@ -102,6 +107,3 @@ watch(() => props.options, (options) => { } }, { deep: true }) - - diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue index 013e8e29c..81026e38f 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue @@ -15,6 +15,11 @@ interface ScriptLeafletGeoJsonEmits { layerremove: [event: Leaflet.LayerEvent] } +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** GeoJSON object, feature, or feature collection. Replace it to update the layer. */ data: GeoJsonObject | GeoJsonObject[] @@ -59,7 +64,3 @@ watch(() => props.options?.style, (style) => { defineExpose({ geoJson }) - - diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue index 2e6039e69..a2ced0671 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue @@ -12,6 +12,11 @@ interface ScriptLeafletTileLayerEmits { tileerror: [event: Leaflet.TileErrorEvent] } +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps<{ /** Tile URL template, for example `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. */ url: string @@ -50,7 +55,3 @@ watch(() => props.options, (options) => { defineExpose({ tileLayer }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue index 01de79cad..5e76ccb06 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue @@ -20,6 +20,11 @@ interface LayerStyle { filter: unknown } +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A template that holds only a +// comment, or nothing, renders nothing on the server, so hydration mismatches. +defineOptions({ render: () => null }) + const props = defineProps() const emit = defineEmits() @@ -429,7 +434,3 @@ watch(styleSignature, (signature) => { defineExpose({ geoJson }) - - diff --git a/test/e2e/map-hydration.test.ts b/test/e2e/map-hydration.test.ts new file mode 100644 index 000000000..444f10cab --- /dev/null +++ b/test/e2e/map-hydration.test.ts @@ -0,0 +1,45 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +/** + * A renderless map component must render the same node on the server and the + * client. A template that holds only a comment, or nothing, renders nothing on + * the server while the client expects a comment node, so hydration reports a + * mismatch. + * + * `@nuxt/test-utils` builds inside the Vitest worker, where `NODE_ENV` is `test`. + * `@vue/compiler-core` picks its development build there, and its `comments` + * option defaults to `true`, so a comment-only template cannot fail. Setting + * `comments: false` applies the production default. The probe test proves it. + */ +const pages = ['/maplibre', '/leaflet', '/google-maps'] + +describe('map component hydration in a production build', { timeout: 120000 }, async () => { + await setup({ + rootDir: resolve('../fixtures/map-hydration'), + browser: true, + nuxtConfig: { + vue: { compilerOptions: { comments: false } }, + }, + }) + + it.each(pages)('strips template comments from %s, like a production build', async (path) => { + const html = await $fetch(path) + expect(html).toContain('
') + expect(html).not.toContain('production-build-probe') + }) + + it.each(pages)('hydrates %s without a mismatch', async (path) => { + const page = await createPage() + const messages: string[] = [] + page.on('console', message => messages.push(`${message.type()}: ${message.text()}`)) + page.on('pageerror', error => messages.push(`pageerror: ${error.message}`)) + await page.goto(url(path), { waitUntil: 'hydration' }) + + expect(messages.filter(message => /hydrat|mismatch/i.test(message))).toEqual([]) + await page.close() + }) +}) diff --git a/test/fixtures/map-hydration/app.vue b/test/fixtures/map-hydration/app.vue new file mode 100644 index 000000000..8f62b8bf9 --- /dev/null +++ b/test/fixtures/map-hydration/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/map-hydration/nuxt.config.ts b/test/fixtures/map-hydration/nuxt.config.ts new file mode 100644 index 000000000..4d05f97ec --- /dev/null +++ b/test/fixtures/map-hydration/nuxt.config.ts @@ -0,0 +1,17 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: [ + '@nuxt/scripts', + ], + scripts: { + registry: { + // No page loads a map SDK. Hydration compares the server HTML with the + // client's first render, which happens before any script loads. + maplibre: { trigger: false }, + leaflet: { trigger: false, bundle: false }, + googleMaps: { trigger: false }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/map-hydration/package.json b/test/fixtures/map-hydration/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/test/fixtures/map-hydration/package.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/map-hydration/pages/google-maps.vue b/test/fixtures/map-hydration/pages/google-maps.vue new file mode 100644 index 000000000..e7b51a2c4 --- /dev/null +++ b/test/fixtures/map-hydration/pages/google-maps.vue @@ -0,0 +1,18 @@ + diff --git a/test/fixtures/map-hydration/pages/leaflet.vue b/test/fixtures/map-hydration/pages/leaflet.vue new file mode 100644 index 000000000..18d50f15d --- /dev/null +++ b/test/fixtures/map-hydration/pages/leaflet.vue @@ -0,0 +1,20 @@ + + + diff --git a/test/fixtures/map-hydration/pages/maplibre.vue b/test/fixtures/map-hydration/pages/maplibre.vue new file mode 100644 index 000000000..a2709d929 --- /dev/null +++ b/test/fixtures/map-hydration/pages/maplibre.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/map-hydration/tsconfig.json b/test/fixtures/map-hydration/tsconfig.json new file mode 100644 index 000000000..4b34df157 --- /dev/null +++ b/test/fixtures/map-hydration/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +}