Skip to content

Commit 8217d9e

Browse files
usualomaautofix-ci[bot]yusukebe
authored
fix(jsx): align link hoisting and dedupe with React 19 (#4792)
* fix(jsx): align link hoisting and dedupe with React 19 * ci: apply automated fixes * test(jsx): fix expected output for link element deduplication * fix: remove unused argument from shouldDeDupeByKey Co-authored-by: Yusuke Wada <yusuke@kamawada.com> * test: Add test to check behavior of uncovered conditional branches --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
1 parent 5086956 commit 8217d9e

5 files changed

Lines changed: 575 additions & 23 deletions

File tree

src/jsx/dom/intrinsic-element/components.test.tsx

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,268 @@ describe('intrinsic element', () => {
313313
await Promise.resolve()
314314
expect(root.innerHTML).toBe('<div><div>Content</div><button>Show</button></div>')
315315
})
316+
317+
describe('React 19 compatibility', () => {
318+
type LinkSignatureInput = {
319+
rel: string
320+
href: string
321+
hrefLang?: string
322+
type?: string
323+
title?: string
324+
media?: string
325+
as?: string
326+
crossOrigin?: string
327+
sizes?: string
328+
dataPrecedence?: string
329+
}
330+
331+
const sig = (v: LinkSignatureInput) =>
332+
[
333+
v.rel,
334+
v.href,
335+
v.hrefLang ?? '',
336+
v.type ?? '',
337+
v.title ?? '',
338+
v.media ?? '',
339+
v.as ?? '',
340+
v.crossOrigin ?? '',
341+
v.sizes ?? '',
342+
v.dataPrecedence ?? '',
343+
].join('|')
344+
345+
const headLinkSignatures = () =>
346+
Array.from(document.head.querySelectorAll('link')).map((el) =>
347+
sig({
348+
rel: el.getAttribute('rel') ?? '',
349+
href: el.getAttribute('href') ?? '',
350+
hrefLang: el.getAttribute('hreflang') ?? '',
351+
type: el.getAttribute('type') ?? '',
352+
title: el.getAttribute('title') ?? '',
353+
media: el.getAttribute('media') ?? '',
354+
as: el.getAttribute('as') ?? '',
355+
crossOrigin: el.getAttribute('crossorigin') ?? '',
356+
sizes: el.getAttribute('sizes') ?? '',
357+
dataPrecedence: el.getAttribute('data-precedence') ?? '',
358+
})
359+
)
360+
361+
const assertHeadLinks = (
362+
node: unknown,
363+
expected: string[],
364+
expectedRootInnerHTML?: string
365+
) => {
366+
render(node as never, root)
367+
expect(headLinkSignatures()).toEqual(expected)
368+
if (expectedRootInnerHTML !== undefined) {
369+
expect(root.innerHTML).toBe(expectedRootInnerHTML)
370+
}
371+
}
372+
373+
it('should keep canonical and alternates in source order', () => {
374+
assertHeadLinks(
375+
<div>
376+
<link rel='canonical' href='https://example.com/en/about' />
377+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
378+
<link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
379+
</div>,
380+
[
381+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
382+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
383+
sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
384+
]
385+
)
386+
})
387+
388+
it('should keep alternate-canonical-alternate order', () => {
389+
assertHeadLinks(
390+
<div>
391+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
392+
<link rel='canonical' href='https://example.com/en/about' />
393+
<link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
394+
</div>,
395+
[
396+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
397+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
398+
sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
399+
]
400+
)
401+
})
402+
403+
it('should not de-duplicate canonical links', () => {
404+
assertHeadLinks(
405+
<div>
406+
<link rel='canonical' href='https://example.com/en/about' />
407+
<link rel='canonical' href='https://example.com/en/about' />
408+
</div>,
409+
[
410+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
411+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
412+
]
413+
)
414+
})
415+
416+
it('should not de-duplicate alternate links', () => {
417+
assertHeadLinks(
418+
<div>
419+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
420+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
421+
</div>,
422+
[
423+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
424+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
425+
]
426+
)
427+
})
428+
429+
it('should de-duplicate stylesheet with precedence', () => {
430+
assertHeadLinks(
431+
<div>
432+
<link rel='stylesheet' href='/style.css' precedence='default' />
433+
<link rel='stylesheet' href='/style.css' precedence='default' />
434+
</div>,
435+
[sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' })]
436+
)
437+
})
438+
439+
it('should not de-duplicate stylesheet against preload with same href', () => {
440+
assertHeadLinks(
441+
<div>
442+
<link rel='preload' href='/style.css' as='style' />
443+
<link rel='stylesheet' href='/style.css' precedence='default' />
444+
<link rel='stylesheet' href='/style.css' precedence='default' />
445+
</div>,
446+
[
447+
sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' }),
448+
sig({ rel: 'preload', href: '/style.css', as: 'style' }),
449+
]
450+
)
451+
})
452+
453+
it('should keep stylesheet links without precedence in root', () => {
454+
assertHeadLinks(
455+
<div>
456+
<link rel='stylesheet' href='/style.css' />
457+
<link rel='stylesheet' href='/style.css' />
458+
</div>,
459+
[],
460+
'<div><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/style.css"></div>'
461+
)
462+
})
463+
464+
it('should keep different stylesheets with same precedence', () => {
465+
assertHeadLinks(
466+
<div>
467+
<link rel='stylesheet' href='/a.css' precedence='default' />
468+
<link rel='stylesheet' href='/b.css' precedence='default' />
469+
</div>,
470+
[
471+
sig({ rel: 'stylesheet', href: '/a.css', dataPrecedence: 'default' }),
472+
sig({ rel: 'stylesheet', href: '/b.css', dataPrecedence: 'default' }),
473+
]
474+
)
475+
})
476+
477+
it('should not de-duplicate preload links', () => {
478+
assertHeadLinks(
479+
<div>
480+
<link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
481+
<link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
482+
</div>,
483+
[
484+
sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
485+
sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
486+
]
487+
)
488+
})
489+
490+
it('should not de-duplicate modulepreload links', () => {
491+
assertHeadLinks(
492+
<div>
493+
<link rel='modulepreload' href='/module.js' />
494+
<link rel='modulepreload' href='/module.js' />
495+
</div>,
496+
[
497+
sig({ rel: 'modulepreload', href: '/module.js' }),
498+
sig({ rel: 'modulepreload', href: '/module.js' }),
499+
]
500+
)
501+
})
502+
503+
it('should keep links from two components', () => {
504+
const Head = () => (
505+
<>
506+
<link rel='canonical' href='https://example.com/en/about' />
507+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
508+
<link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
509+
</>
510+
)
511+
const Body = () => (
512+
<>
513+
<link rel='canonical' href='https://example.com/en/about' />
514+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
515+
<link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
516+
</>
517+
)
518+
assertHeadLinks(
519+
<div>
520+
<Head />
521+
<Body />
522+
</div>,
523+
[
524+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
525+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
526+
sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
527+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
528+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
529+
sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
530+
]
531+
)
532+
})
533+
534+
it('should hoist from deep nested component and keep duplicates', () => {
535+
const Nested = () => (
536+
<div>
537+
<div>
538+
<link rel='canonical' href='https://example.com/en/about' />
539+
</div>
540+
</div>
541+
)
542+
assertHeadLinks(
543+
<div>
544+
<link rel='canonical' href='https://example.com/en/about' />
545+
<Nested />
546+
</div>,
547+
[
548+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
549+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
550+
]
551+
)
552+
})
553+
554+
it('should keep mixed links and de-duplicate only stylesheet', () => {
555+
assertHeadLinks(
556+
<div>
557+
<link rel='canonical' href='https://example.com/en/about' />
558+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
559+
<link rel='stylesheet' href='/style.css' precedence='default' />
560+
<link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
561+
<link rel='canonical' href='https://example.com/en/about' />
562+
<link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
563+
<link rel='stylesheet' href='/style.css' precedence='default' />
564+
<link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
565+
</div>,
566+
[
567+
sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' }),
568+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
569+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
570+
sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
571+
sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
572+
sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
573+
sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
574+
]
575+
)
576+
})
577+
})
316578
})
317579

318580
describe('style element', () => {
@@ -719,6 +981,42 @@ describe('intrinsic element', () => {
719981
expect(onError).toBeCalledTimes(1)
720982
})
721983

984+
it('should not register load handlers when the tag has no de-duplication key', () => {
985+
const addEventListener = vi.fn<HTMLElement['addEventListener']>()
986+
const onLoad = vi.fn()
987+
const onError = vi.fn()
988+
989+
const App = () => {
990+
return (
991+
<div>
992+
<title
993+
ref={(e: HTMLTitleElement) => {
994+
if (!e) {
995+
return
996+
}
997+
const originalAddEventListener = e.addEventListener.bind(e)
998+
addEventListener.mockImplementation((...args) =>
999+
originalAddEventListener(...args)
1000+
)
1001+
e.addEventListener = addEventListener
1002+
}}
1003+
onLoad={onLoad}
1004+
onError={onError}
1005+
>
1006+
Document Title
1007+
</title>
1008+
Content
1009+
</div>
1010+
)
1011+
}
1012+
render(<App />, root)
1013+
expect(document.head.innerHTML).toBe('<title>Document Title</title>')
1014+
expect(root.innerHTML).toBe('<div>Content</div>')
1015+
expect(addEventListener).not.toBeCalled()
1016+
expect(onLoad).not.toBeCalled()
1017+
expect(onError).not.toBeCalled()
1018+
})
1019+
7221020
it('should be blocked by blocking attribute', async () => {
7231021
const Component = () => {
7241022
return (

0 commit comments

Comments
 (0)