Forráskód Böngészése

fix(hydration): update hydration mismatch checks for asset URL SSR/CSR differences

daiwei 2 hónapja
szülő
commit
000425b8cf

+ 49 - 0
packages/runtime-core/__tests__/hydration.spec.ts

@@ -2236,6 +2236,55 @@ describe('SSR hydration', () => {
       expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
     })
 
+    test('asset url attrs allow client contains server', () => {
+      try {
+        __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ = true
+
+        mountWithHydration(`<img src="/a.png">`, () =>
+          h('img', { src: 'http://localhost:3000/a.png' }),
+        )
+        mountWithHydration(`<a href="/a.png"></a>`, () =>
+          h('a', { href: 'http://localhost:3000/a.png' }),
+        )
+        mountWithHydration(`<video poster="/a.png"></video>`, () =>
+          h('video', { poster: 'http://localhost:3000/a.png' }),
+        )
+        mountWithHydration(`<object data="/a.png"></object>`, () =>
+          h('object', { data: 'http://localhost:3000/a.png' }),
+        )
+        mountWithHydration(
+          `<svg><use xlink:href="/sprite.svg#icon"></use></svg>`,
+          () =>
+            h('svg', [
+              h('use', {
+                'xlink:href': 'http://localhost:3000/sprite.svg#icon',
+              }),
+            ]),
+        )
+        mountWithHydration(`<img srcset="/a.png 1x, /b.png 2x">`, () =>
+          h('img', {
+            srcset:
+              'http://localhost:3000/a.png 1x, http://localhost:3000/b.png 2x',
+          }),
+        )
+        expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+      } finally {
+        __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ = false
+      }
+    })
+
+    test('asset url attrs still warn when client does not contain server', () => {
+      try {
+        __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ = true
+        mountWithHydration(`<img src="/a.png">`, () =>
+          h('img', { src: 'http://localhost:3000/b.png' }),
+        )
+        expect(`Hydration attribute mismatch`).toHaveBeenWarned()
+      } finally {
+        __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ = false
+      }
+    })
+
     test('attr special case: textarea value', () => {
       mountWithHydration(`<textarea>foo</textarea>`, () =>
         h('textarea', { value: 'foo' }),

+ 90 - 1
packages/runtime-core/src/hydration.ts

@@ -866,7 +866,23 @@ function propHasMismatch(
         ? String(clientValue)
         : false
     }
-    if (actual !== expected) {
+
+    // #14370, when mismatch details are enabled, tolerate asset URL differences
+    // caused by Vite's `new URL(..., import.meta.url)` behavior in SSR vs client:
+    // SSR can't know the browser origin, so it may render "/a.png" while the
+    // client renders "http://host/a.png". This tends to show up in PROD builds
+    // where assets are resolved as URLs. This is a dev/check-only relaxation to
+    // avoid noisy warnings for asset URLs.
+    if (
+      actual !== expected &&
+      !(
+        __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ &&
+        isString(actual) &&
+        isString(expected) &&
+        isAssetUrlLikeAttr(key) &&
+        isSameAssetUrl(actual, expected, key)
+      )
+    ) {
       mismatchType = MismatchTypes.ATTRIBUTE
       mismatchKey = key
     }
@@ -935,6 +951,79 @@ function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
   return true
 }
 
+function isAssetUrlLikeAttr(key: string): boolean {
+  return (
+    key === 'src' ||
+    key === 'href' ||
+    key === 'xlink:href' ||
+    key === 'poster' ||
+    key === 'data' ||
+    key === 'srcset'
+  )
+}
+
+function isSameAssetUrl(
+  actual: string,
+  expected: string,
+  key: string,
+): boolean {
+  if (key === 'srcset') {
+    return isSameSrcSet(actual, expected)
+  }
+  return matchUrl(actual, expected)
+}
+
+function isSameSrcSet(actual: string, expected: string): boolean {
+  const actualSet = parseSrcSet(actual)
+  const expectedSet = parseSrcSet(expected)
+  if (!actualSet || !expectedSet || actualSet.length !== expectedSet.length) {
+    return false
+  }
+  for (let i = 0; i < actualSet.length; i++) {
+    const a = actualSet[i]
+    const e = expectedSet[i]
+    if (a.descriptor !== e.descriptor) {
+      return false
+    }
+    if (a.url == null || e.url == null || !matchUrl(a.url, e.url)) {
+      return false
+    }
+  }
+  return true
+}
+
+function parseSrcSet(
+  srcset: string,
+): Array<{ url: string | null; descriptor: string }> | null {
+  const parts = srcset
+    .split(',')
+    .map(p => p.trim())
+    .filter(Boolean)
+  if (!parts.length) {
+    return null
+  }
+  const result: Array<{ url: string | null; descriptor: string }> = []
+  for (const part of parts) {
+    const match = part.match(/^(\S+)(?:\s+(.+))?$/)
+    if (!match) {
+      return null
+    }
+    const rawUrl = match[1]
+    const descriptor = (match[2] || '').trim()
+    result.push({ url: rawUrl, descriptor })
+  }
+  return result
+}
+
+function matchUrl(serverValue: string, clientValue: string): boolean {
+  const server = serverValue.trim()
+  const client = clientValue.trim()
+  if (!server || !client) {
+    return false
+  }
+  return client.endsWith(server)
+}
+
 function resolveCssVars(
   instance: ComponentInternalInstance,
   vnode: VNode,