Skip to content

fix(grid-marquee): dedupe drawer video when face link is auto-decorated to inline <video> (MEP replacePage) - #711

Merged
hadobe merged 2 commits into
stagefrom
grid-marquee-dupe-fix
Aug 26, 2026
Merged

fix(grid-marquee): dedupe drawer video when face link is auto-decorated to inline <video> (MEP replacePage)#711
hadobe merged 2 commits into
stagefrom
grid-marquee-dupe-fix

Conversation

@echen-adobe

@echen-adobe echen-adobe commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes duplicate videos rendering in the grid-marquee block, seen on the Saudi Arabia English homepage (delivered via an MEP replacePage fragment). A card would show its preview/drawer video twice — once as a stray inline <video> left inside the card face, and again as the drawer video the block builds itself.

The block's toCard() only knew how to strip the drawer-video link when it was authored as an <a href="*.mp4">. When the same content is served through the MEP page-replacement flow, that .mp4 link gets auto-decorated into an inline <video> before the block runs, so toCard() never removed it — and then created a second video in the drawer. This PR makes the block tolerant of both authored forms and removes whichever is present, so the face can never duplicate the drawer video.


Jira Ticket

Resolves: MWPW-204683

Note: SA/HUMAIN Arabic Express homepage work. Please confirm this is the correct ticket for the dupe-video defect vs. the parent Arabic-homepage epic.


Root cause — in depth

How the drawer video is normally authored

Each card in grid-marquee is authored as a face div followed by one or more panel divs. The face carries three stacked paragraphs:

<p><picture><img ></picture></p>          <!-- static card thumbnail -->
<p><a href="…/video.mp4"></a></p>          <!-- source for the drawer video -->
<p><strong>Card title</strong></p>

toCard() is supposed to (1) read the .mp4 URL off that anchor, (2) remove the anchor from the face so only the thumbnail shows, and (3) lazily build the real <video> inside the drawer on hover/open.

The #_dnb contract that normally protects this

Express globally auto-converts a[href$=".mp4"] links into autoplaying inline <video> elements. grid-marquee opts out of that so it can manage the link itself. In scripts/utils.js (decorateAreavideoLinksToNotAutoBlock, ~line 847), every .mp4 link inside a grid-marquee gets a #_dnb ("do not block") suffix appended:

let videoLinksToNotAutoBlock = [ , 'grid-marquee',  ]
  .map((block) => `${selector} .${block} a[href$=".mp4"]`).join(', ');
linksToNotAutoblock.forEach((link) => {
  if (!link.href.includes('#_dnb')) link.href = `${link.href}#_dnb`;
});

On an ordinary page this runs over the page area before autoblocking, so the link stays an <a> and toCard() behaves.

Why it breaks under MEP replacePage

On the SA homepage the entire page is swapped for a personalization fragment:

action=replacePage → …/fragments/personalization/humain-arabic-express/humain-arabic-express-homepage

When content arrives through that page-replacement path, the grid-marquee .mp4 links are not guaranteed to receive the #_dnb guard before Express's auto-video decoration runs. So the .mp4 link in the face is converted into an inline <video> first.

Now toCard() runs against a face that contains a <video>, not an <a>:

// BEFORE
const videoAnchor = face.querySelector('a');  // null — it's a <video> now
videoAnchor?.remove();                         // no-op → inline video stays in the face

const lazyCB = () => decorateDrawer(videoAnchor.href, ); // also throws on null.href

Result:

  1. The auto-decorated inline <video> is left in the card face (visible dupe sync stage #1).
  2. The block still builds its own drawer <video> (dupe Stage to Main #2).
  3. videoAnchor.href on null throws inside lazyCB, breaking drawer decoration for that card.

This is the same failure the T&O team patched a year ago with a full updateBlockCode fork of the block (scripts/mep/ace1057/grid-marquee/), whose toCard() used face.querySelector('a') || face.querySelector('video'). That fork has since diverged completely from the live block ("isn't fully compatible with the latest block code"), which is why the fix now lands in the real block instead of another throwaway override.


The fix

toCard() now resolves the drawer-video source from either an <a> or an already-inlined <video>, and removes both from the face:

const faceAnchor = face.querySelector('a');
const faceVideo = face.querySelector('video');
const videoSrc = faceAnchor?.href
  || faceVideo?.querySelector('source')?.src
  || faceVideo?.currentSrc
  || faceVideo?.getAttribute('src')
  || faceVideo?.dataset?.videoSource;
faceAnchor?.remove();
faceVideo?.remove();

const lazyCB = () => decorateDrawer(videoSrc, getPosterSrc(face.querySelector('img')), );

Key properties:

  • No-op on the happy path. When the face is authored the normal way (<a> present, no inline video), faceVideo is null, videoSrc === faceAnchor.href, and behavior is byte-for-byte identical to before. Every existing grid-marquee page is unaffected.
  • Anchor still wins for the source URL, preserving the canonical authored .mp4 (incl. any #_dnb suffix, which the media loader ignores — unchanged from prior behavior).
  • Removes both elements, so even a partially-converted face (both an <a> and a <video>) can't leave a dupe.
  • No more null.href throwvideoSrc degrades gracefully to undefined (drawer still opens with title/CTAs) instead of crashing decoration.

Secondary change — anti-FOUC CSS guard (separable)

grid-marquee.css also gains a one-line guard that hides the raw authored rows until the block builds .foreground:

.grid-marquee:not(:has(> .foreground)) > div { visibility: hidden; }

This addresses a separate, cosmetic symptom (raw anchor/link text briefly painting under the thumbnail during the block's staged decoration), not the dupe itself. It is fully separable from the JS fix — see Potential Regressions. Happy to drop it if we'd rather keep this PR laser-focused on the dupe.


Test URLs

Env URL
Before (MEP repro) https://main--da-express-milo--adobecom.aem.page/express/?mep=%2Fexpress%2Fpersonalization%2Fgrid-marquee-ios-pzn.json--default---%2Fexpress%2Fpersonalization%2Fbts-promo-fy26-pzn.json--countryip%28us%2Cca%29---%2Fexpress%2Fpersonalization%2Fairtel-in-przn-174994.json--default---%2Fexpress%2Fpersonalization%2Findosat-id.json--default---%2Fexpress%2Fpersonalization%2Fhumain-arabic-express-pzn.json--countryip%28sa%29
After (MEP repro) https://grid-marquee-dupe-fix--da-express-milo--adobecom.aem.page/express/?mep=%2Fexpress%2Fpersonalization%2Fgrid-marquee-ios-pzn.json--default---%2Fexpress%2Fpersonalization%2Fbts-promo-fy26-pzn.json--countryip%28us%2Cca%29---%2Fexpress%2Fpersonalization%2Fairtel-in-przn-174994.json--default---%2Fexpress%2Fpersonalization%2Findosat-id.json--default---%2Fexpress%2Fpersonalization%2Fhumain-arabic-express-pzn.json--countryip%28sa%29
After (direct fragment) https://grid-marquee-dupe-fix--da-express-milo--adobecom.aem.page/express/fragments/personalization/humain-arabic-express/humain-arabic-express-homepage?martech=off

The MEP repro links apply the actual personalization manifest chain — including humain-arabic-express-pzn.json scoped to countryip(sa) — which is the flow that triggers the auto-<video> conversion and the duplicate. The direct fragment link renders the affected grid-marquee on its own, useful for isolating the block without SA geo.


Verification Steps

  1. Open the After URL and locate the grid-marquee cards.
  2. Hover/open a card to reveal its drawer.
  3. Before: the card face shows a playing inline video and the drawer shows a second copy of the same video (dupe); the browser console shows a Cannot read properties of null (reading 'href') from grid-marquee.js.
  4. After: each card face shows only the static thumbnail; exactly one video renders, inside the drawer; no console error.
  5. Regression sanity: open any standard (non-MEP) grid-marquee page and confirm cards, drawers, tabs, ratings and CTAs behave exactly as before.

Potential Regressions

Reviewed carefully; the JS change is a no-op on the normal authoring path, so risk concentrates in a few edge cases:

JS (toCard)

  • Intentional face video removed? The block has no styling or design role for a <video> in the face (no .face video CSS in this block or the historical override) — the face is always a static thumbnail — so removing a stray inline video is safe by design.
  • videoSrc unresolved. If a face somehow has neither an <a> nor a resolvable video source, videoSrc is undefined and the drawer builds <source src="undefined"> (video simply won't load). This is strictly better than the previous behavior, which threw and broke drawer decoration entirely.
  • Poster resolution. getPosterSrc(face.querySelector('img')) still reads the first face <img> (the authored thumbnail, which precedes the video link), so removing the inline <video> does not affect the drawer poster — unless authoring ever places the video before the thumbnail image (not the case in any known authoring).
  • #_dnb in the source URL. Unchanged from before — the anchor href already carried it; the media loader ignores non-#t= fragments.

CSS (anti-FOUC guard)

  • Blank block if init never runs. The guard keys off .foreground, which the block appends synchronously at the start of init. If block JS fails to load/execute, the block stays visibility: hidden (blank) instead of showing raw content. Rare, but a real trade-off — this is the main reason the guard is called out as separable.
  • :has() support. Baseline across current evergreen browsers (Express's targets); no polyfill needed, but flagging since it's a relatively new selector for this codebase's block CSS.
  • LCP/paint timing. visibility: hidden preserves layout (no added CLS), but briefly defers paint of the authored rows until .foreground exists. The new-authoring path already removes images up front for LCP, so net impact is negligible; worth an eyeball on a Lighthouse/CWV run if the marquee image is the LCP element.

Broad regression surface to spot-check (branch builds of grid-marquee surfaces):


Additional Notes

  • Unit tests (test/blocks/grid-marquee/grid-marquee.test.js) pass unchanged. A follow-up commit can add an explicit regression test asserting exactly one <video> renders when the face is authored with (or auto-decorated into) an inline <video>.
  • This intentionally supersedes the stale scripts/mep/ace1057/grid-marquee/ fork approach; folding the <a>-or-<video> handling into the live block keeps it from rotting against future block changes.
  • Longer-term hardening worth discussing separately: ensure the #_dnb de-autoblock pass (utils.js videoLinksToNotAutoBlock) also runs over MEP replacePage fragment content, which would prevent the auto-conversion at the source.

@aem-code-sync

aem-code-sync Bot commented Aug 25, 2026

Copy link
Copy Markdown

Hello, I'm the AEM Code Sync Bot and I will run some actions to deploy your branch and validate page speed.
In case there are problems, just click a checkbox below to rerun the respective action.

  • Re-run all PSI checks
  • Re-run failed PSI checks
  • Re-sync branch
Commits

@echen-adobe echen-adobe changed the title Grid marquee dupe fix fix(grid-marquee): dedupe drawer video when face link is auto-decorated to inline <video> (MEP replacePage) Aug 25, 2026
@echen-adobe
echen-adobe marked this pull request as ready for review August 25, 2026 23:37
@echen-adobe echen-adobe added the Ready for Review Ready for peer review. label Aug 25, 2026

@fullcolorcoder fullcolorcoder left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@echen-adobe I think this is fine to ship as is. Some follow up notes on the FOUC stuff at https://github.com/adobecom/da-express-milo/pull/711/changes#diff-d0e2f9f7bdad2655bc446c8058bc3d1c2758bb3145fec38de1b2fcd167a63741R12

I don't anticipate regression but some notes for you/agent to consider.

Problem 1 — in new-authoring mode the guard probably doesn't even prevent the FOUC it's written for

The gate is :not(:has(> .foreground)). But look at the new-mode flow (grid-marquee.js:409 onward):

el.append(foreground);              // .foreground exists NOW (empty) — guard turns OFF here
...
requestAnimationFrame(() => {
  restoreImages();
  const cards = items.map((item) => toCard(item));   // anchors stripped HERE, a frame later
  foreground.append(cardsContainer);                 // items moved into .foreground HERE
});

.foreground is appended empty, synchronously, and the authored item <div>s aren't moved into it until the rAF fires a frame later. So the guard's condition (.foreground exists) flips to "safe" before the content it's guarding has actually been decorated/moved. During the window where raw anchor text could paint, .foreground already exists → the rule is inert. The gate is keyed on the wrong event: "an empty container exists" instead of "the content has been decorated."

(In practice the rAF-before-paint ordering already covers most of this window on its own — which makes the guard largely redundant where it's correct and ineffective where it isn't.)

Problem 2 — it can permanently blank the LCP headline if JS fails (your §4 anti-pattern)

In legacy mode the authored <h1> is a direct > div child until init moves it into .foreground. The guard hides every > div until .foreground exists. If grid-marquee's JS ever fails to load/execute, Milo still un-hides the section — but .foreground was never created, so the guard stays active and the H1 (frequently the page's LCP on a hero grid-marquee) is visibility: hidden forever. That's exactly the "never hide authored LCP content" rule in the project's own performance doc (§4).

The better shape

The FOUC is raw link/inline-video content painting — so hide that specific element, not the whole block. A scoped rule can't blank the block or the LCP and doesn't depend on decoration-timing races:

/* hide only the un-decorated video source, in both authored forms */
.grid-marquee .face > p > a[href*=".mp4"],
.grid-marquee .face > p > video { visibility: hidden; }

(Or, if a block-wide guard is really wanted, key it on a positive success signal added at the end of decoration — .grid-marquee.is-decorated — with a CSS-animation fallback that reveals after ~Ns so a JS failure can never leave it blank. But scoping to the offending element is simpler and strictly safer.)

@hadobe hadobe removed the Ready for Review Ready for peer review. label Aug 26, 2026
@hadobe
hadobe merged commit eda1981 into stage Aug 26, 2026
24 of 26 checks passed
@hadobe hadobe mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants