@@ -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