Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/runtime-core/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
81 changes: 80 additions & 1 deletion packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
def,
getEscapedCssVarName,
includeBooleanAttr,
isAssetUrlAttr,
isBooleanAttr,
isKnownHtmlAttr,
isKnownSvgAttr,
Expand Down Expand Up @@ -866,7 +867,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) &&
isAssetUrlAttr(key) &&
isSameAssetUrl(actual, expected, key)
)
) {
mismatchType = MismatchTypes.ATTRIBUTE
mismatchKey = key
}
Expand Down Expand Up @@ -935,6 +952,68 @@ function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
return true
}

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,
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/domAttrConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ export const isKnownMathMLAttr: (key: string) => boolean =
`voffset,width,widths,xlink:href,xlink:show,xlink:type,xmlns`,
)

/**
* Asset URL-like attributes that may differ between SSR and client due to
* origin resolution.
*/
export const isAssetUrlAttr: (key: string) => boolean = /*@__PURE__*/ makeMap(
'src,href,xlink:href,poster,data,srcset',
)

/**
* Shared between server-renderer and runtime-core hydration logic
*/
Expand Down
Loading