@@ -417,6 +417,162 @@ describe('useComposer link previews', () => {
417417 expect ( built . post . links ) . toHaveLength ( 1 ) ;
418418 } ) ;
419419
420+ it ( 'drops the stale card as soon as the url is swapped' , async ( ) => {
421+ jest . useFakeTimers ( ) ;
422+ try {
423+ const { result } = await renderComposer ( ) ;
424+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
425+ // The loading state waits for the debounced fetch to actually start.
426+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
427+
428+ await act ( async ( ) => {
429+ jest . advanceTimersByTime ( 1000 ) ;
430+ } ) ;
431+ expect ( result . current . linkPreview ) . not . toBeNull ( ) ;
432+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
433+
434+ // Swap the url: the stale card disappears right away, before the new
435+ // fetch (and its loading state) kicks in.
436+ act ( ( ) => result . current . setText ( 'now https://other.example instead' ) ) ;
437+ expect ( result . current . linkPreview ) . toBeNull ( ) ;
438+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
439+
440+ await act ( async ( ) => {
441+ jest . advanceTimersByTime ( 1000 ) ;
442+ } ) ;
443+ expect ( result . current . linkPreview ) . toMatchObject ( {
444+ url : 'https://other.example' ,
445+ } ) ;
446+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
447+ } finally {
448+ jest . useRealTimers ( ) ;
449+ }
450+ } ) ;
451+
452+ it ( 'disables posting until the preview finishes loading' , async ( ) => {
453+ jest . useFakeTimers ( ) ;
454+ try {
455+ const unfurl = deferred < Awaited < ReturnType < typeof mockClient . urlInfo > > > ( ) ;
456+ mockClient . urlInfo . mockReturnValueOnce ( unfurl . promise ) ;
457+ const { result } = await renderComposer ( ) ;
458+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
459+
460+ // Debounce elapses, the fetch starts: posting is held.
461+ await act ( async ( ) => {
462+ jest . advanceTimersByTime ( 1000 ) ;
463+ } ) ;
464+ expect ( result . current . linkPreviewLoading ) . toBe ( true ) ;
465+ expect ( result . current . canPost ) . toBe ( false ) ;
466+
467+ await act ( async ( ) => {
468+ unfurl . resolve ( {
469+ url : 'https://example.com' ,
470+ title : 't' ,
471+ description : 'd' ,
472+ image : 'i' ,
473+ } ) ;
474+ } ) ;
475+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
476+ expect ( result . current . canPost ) . toBe ( true ) ;
477+ } finally {
478+ jest . useRealTimers ( ) ;
479+ }
480+ } ) ;
481+
482+ it ( 're-enables posting when a loading preview is removed' , async ( ) => {
483+ jest . useFakeTimers ( ) ;
484+ try {
485+ // Never resolves: the user removes the preview instead of waiting.
486+ mockClient . urlInfo . mockReturnValueOnce ( new Promise ( ( ) => { } ) ) ;
487+ const { result } = await renderComposer ( ) ;
488+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
489+
490+ await act ( async ( ) => {
491+ jest . advanceTimersByTime ( 1000 ) ;
492+ } ) ;
493+ expect ( result . current . canPost ) . toBe ( false ) ;
494+
495+ act ( ( ) => result . current . handleRemoveLinkPreview ( ) ) ;
496+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
497+ expect ( result . current . canPost ) . toBe ( true ) ;
498+ } finally {
499+ jest . useRealTimers ( ) ;
500+ }
501+ } ) ;
502+
503+ it ( 'revives previews when a different link is typed after dismissal' , async ( ) => {
504+ jest . useFakeTimers ( ) ;
505+ try {
506+ const { result } = await renderComposer ( ) ;
507+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
508+ await act ( async ( ) => {
509+ jest . advanceTimersByTime ( 1000 ) ;
510+ } ) ;
511+ expect ( result . current . linkPreview ) . not . toBeNull ( ) ;
512+
513+ act ( ( ) => result . current . handleRemoveLinkPreview ( ) ) ;
514+ expect ( result . current . linkPreview ) . toBeNull ( ) ;
515+
516+ act ( ( ) => result . current . setText ( 'now https://other.example instead' ) ) ;
517+ await act ( async ( ) => {
518+ jest . advanceTimersByTime ( 1000 ) ;
519+ } ) ;
520+ expect ( result . current . linkPreview ) . toMatchObject ( {
521+ url : 'https://other.example' ,
522+ } ) ;
523+ } finally {
524+ jest . useRealTimers ( ) ;
525+ }
526+ } ) ;
527+
528+ it ( 'revives previews when the same link is deleted and retyped' , async ( ) => {
529+ jest . useFakeTimers ( ) ;
530+ try {
531+ const { result } = await renderComposer ( ) ;
532+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
533+ await act ( async ( ) => {
534+ jest . advanceTimersByTime ( 1000 ) ;
535+ } ) ;
536+ act ( ( ) => result . current . handleRemoveLinkPreview ( ) ) ;
537+ expect ( result . current . linkPreview ) . toBeNull ( ) ;
538+
539+ // Delete the link and let the draft settle: the removal enters the
540+ // diff baseline, so retyping the very same url counts as newly typed
541+ // and unfurls again. (Deleting and retyping within one debounce window
542+ // coalesces into "no change" — the settled diff never sees it.)
543+ act ( ( ) => result . current . setText ( 'check out' ) ) ;
544+ await act ( async ( ) => {
545+ jest . advanceTimersByTime ( 1000 ) ;
546+ } ) ;
547+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
548+ await act ( async ( ) => {
549+ jest . advanceTimersByTime ( 1000 ) ;
550+ } ) ;
551+ expect ( result . current . linkPreview ) . toMatchObject ( {
552+ url : 'https://example.com' ,
553+ } ) ;
554+ } finally {
555+ jest . useRealTimers ( ) ;
556+ }
557+ } ) ;
558+
559+ it ( 'drops the preview and stops embedding once dismissed' , async ( ) => {
560+ const { result } = await renderComposer ( ) ;
561+ act ( ( ) => result . current . setText ( draftWithUrl ) ) ;
562+
563+ act ( ( ) => result . current . handleRemoveLinkPreview ( ) ) ;
564+ expect ( result . current . linkPreview ) . toBeNull ( ) ;
565+ expect ( result . current . linkPreviewLoading ) . toBe ( false ) ;
566+
567+ await act ( async ( ) => {
568+ await result . current . handlePost ( ) ;
569+ } ) ;
570+
571+ expect ( mockClient . urlInfo ) . not . toHaveBeenCalled ( ) ;
572+ const built = mockClient . contentManager . build . mock . calls [ 0 ] [ 0 ] ;
573+ expect ( built . post . links ) . toHaveLength ( 0 ) ;
574+ } ) ;
575+
420576 it ( 'skips link preview generation when disabled' , async ( ) => {
421577 mockLinkPreviewsEnabled = false ;
422578 const { result } = await renderComposer ( ) ;
0 commit comments