Problem/Motivation
Full disclosure, this issue was analyzed, and written with AI assistance.
There have been several issues addressing BigPipe compatibility in this module (#3361143: Compatibility with BigPipe). The fix introduced in that issue added a guard inside onceCompat() in blazy.once.js that the module's own comment acknowledges is incomplete:
// Tested at D10.3, a workaround, not a final fix, till BigPipe issues fixed.
// ...
// @todo update this if blazy ajax-related is broken later, that is when
// BigPipe fixes this issue.
This issue is the "broken later" case that comment anticipated.
When BigPipe is enabled and a page has any pending placeholder IDs in drupalSettings.bigPipePlaceholderIds, the init function in blazy.load.js — the one that creates the IntersectionObserver instance stored at Drupal.blazy.init — is blocked from running. This means Blazy's image observer is never set up, and lazy-loaded images are never loaded.
The onceCompat() function in blazy.once.js wraps Drupal core's once(). At the top of onceCompat():
if (!$.wwoBigPipeDone()) {
return els;
}
$.wwoBigPipeDone() (defined in dblazy.js) returns false whenever drupalSettings.bigPipePlaceholderIds is non-empty. This guard was added to prevent double-processing of elements during BigPipe fragment replacement, which is correct for per-element processing. The problem is that the same guard also blocks the IntersectionObserver setup, which needs to happen regardless of BigPipe state.
In Drupal.behaviors.blazy.attach() in blazy.load.js, both calls go through the same gated wrapper:
// Per-container processing. BigPipe gate is appropriate here.
$.once(process.bind(me), ID_ONCE, S_ELEMENT, context);
// Creates Drupal.blazy.init (the IntersectionObserver instance).
// BigPipe gate is NOT appropriate here.
$.once(init.bind(me), ID_ONCE_GLOBAL, S_GLOBAL, context);
The second call, which runs the init function that ultimately executes me.init = me.run(me.options) and stores the IntersectionObserver instance at Drupal.blazy.init, is blocked while any BigPipe placeholder is pending on the page.
Why BigPipe resolving does not always recover the situation
When BigPipe eventually streams its last placeholder replacement, it calls Drupal.attachBehaviors() on the new fragment. At that point $.wwoBigPipeDone() returns true, and the init function can run. However, on pages that use Slick with lazyLoad: blazy, Slick's own behavior will have already initialized and positioned its slides during the window when Blazy was blocked. This is part of a separate issue that also needs to be filed with Slick.
The SVG placeholder images Blazy inserts have a natural size of 1×1 pixel. Once Slick initializes around images of that size, the slide containers can be measured and laid out at or near zero height. After that, when Blazy's IntersectionObserver is finally set up, it observes those images — but the IntersectionObserver specification states that elements with zero intersection area never dispatch intersection events, even with threshold: 0. The observer is active but fires nothing. The images remain as placeholders indefinitely.
Steps to reproduce
My apologies that I don't have reliable, clear instructions on reproducing. It's been an inconsistent issue on the site I'm working on, and quite a challenge to get here with it. I'll try to provide more info in the future if needed.
Proposed resolution
The BigPipe guard in onceCompat() is correct for the per-container process call in blazy.load.js: you do not want to mark containers as processed before BigPipe has injected their content. The guard is incorrect for the init call because the IntersectionObserver must be set up before BigPipe resolves so it is ready to observe elements as BigPipe injects them.
In blazy.load.js
Replace the gated $.once call for the observer setup with a direct call to the core once() library, bypassing the BigPipe guard in onceCompat(). In Drupal 10, core/once exposes the once() function as window.once; Drupal.once is provided as an alias in some versions but not all, so the same pattern used in blazy.once.js (Drupal.once || _win.once) is the safe form. Inside the IIFE in blazy.load.js, _win is already bound to window:
// Before:
$.once(init.bind(me), ID_ONCE_GLOBAL, S_GLOBAL, context);
// After:
var _coreOnce = Drupal.once || _win.once;
var _roots = _coreOnce(ID_ONCE_GLOBAL, S_GLOBAL, $.context(context, S_GLOBAL));
if (_roots.length) {
init.call(me, _roots[0]);
}
The detach side should remain unchanged. $.once.removeSafely() already contains its own $.wwoBigPipeDone() guard, which correctly prevents the once-marking from being removed from body while BigPipe placeholder replacements are still in progress. Replacing it with an unconditional once.remove() causes the observer to be torn down and recreated on every BigPipe replacement event, calling the init function multiple times.
There are related issues in the Slick module that compound this problem when it manifests on a page with a Slick thumbnail nav. Those will be filed separately in the Slick issue queue.
Remaining tasks
Review and confirm the proposed fixes, particularly whether using Drupal.once() directly for ID_ONCE_GLOBAL creates any regression for the AJAX/infinite scroll cases that #3361143: Compatibility with BigPipe was originally addressing.
Issue fork blazy-3588885
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
bburgComment #3
gausarts commentedThank you for the detailed analysis and investigation.
Before changing the current BigPipe gating behavior in Blazy core, I need to clarify an important architectural detail because the proposed resolution assumes a lifecycle ordering that does not fully match the official Blazy/Slick integration.
In the official ecosystem:
This means that under the official integration, the following sequence should normally NOT happen:
because both behaviors are gated by the same "$.once()" logic.
The proposed patch bypasses this synchronization by using raw "core/once" for the global Blazy init. That is a significant architectural change because it allows Blazy observer activation to begin during ongoing BigPipe replacement cycles — the exact condition the current gate was originally introduced to prevent after multiple historical BigPipe/AJAX regressions.
The current gate was added specifically to avoid:
and those issues were reproducible historically.
Because there is currently no minimal reproduction for this issue, I need to confirm whether the affected site may be bypassing the official synchronized lifecycle.
Specifically:
1. Are you using any custom Slick initialization scripts, inline ".slick()" calls, theme JS, or third-party integrations outside the official "slick.load.min.js" behavior?
2. Have you disabled the official Slick loader behavior and replaced it with custom startup logic?
3. Are any custom scripts using raw "Drupal.once()" / "window.once()" / "core/once" directly instead of the Blazy "$.once()" wrapper?
4. Are there any manually-triggered "Drupal.attachBehaviors()" calls, AJAX fragment injections, or custom BigPipe-related behaviors on the affected page?
5. Does the issue persist with the latest DEV? It contains "bio.ajax.min.js" update to avoid last win (LIFO) issues with custom scripts doing the same thing.
6. Can you confirm whether the issue reproduces using only:
This distinction is important because if custom code bypasses the shared Blazy gate while Blazy itself remains gated, then the reported timing sequence becomes possible:
In that scenario, the issue would likely be caused by lifecycle desynchronization introduced outside the official gated ecosystem, rather than by the gate itself.
Without confirming that the issue reproduces under the official synchronized lifecycle, I am hesitant to bypass the existing BigPipe gate globally because doing so risks reintroducing the original BigPipe/AJAX instability problems that the gate was specifically designed to solve.
Similar to when working with other modules, if using core/once or custom initializer relative to Blazy, the fix is straightforward:
1. Add
blazy/loadas a dependency for your library, or better make it heavier than -1.5, in case blazy/load is deprecated someday. Slick has explicit 0 for this purpose. Those explicit weights were designed for soft dependency.2. On Drupal.behaviors.attach(), replace core/once (window.once || Drupal.once):
with dBlazy.once:
.forEach() is optional if a return value is expected like your sample.
or inclusive .forEach():
The last is the legacy dBlazy.once before core/once existed and has similar return values like core/once.
3. On Drupal.behaviors.detach(), replace once.remove(...) with dBlazy.once.removeSafely(...) using the same signature as core/once.
This maintains the same gate and correct hierarchy like any Blazy's submodules. Without a deterministic order, chaotic executions are expected under BigPipe storms. Unlocking the gate will be just like opening the Pandora box.
Other possibility is weight being shuffled, but we don't want to speculate about basic math problem in core.
We should change it back to Bug, once you have a repro.
Let me know?
Comment #4
bburgThank you for your quick and detailed response. I confess, this was a challenge to debug, and your questions helped me identify an issue in another "pseudo" custom module (not my custom module but one I'm forced to use, distributed within a client's organization). It does appear to be using core once() directly. So it would seem I need to look there to debug this if that's likely bypassing the intended gating system in the Slick/Blazy ecosystem.
So perhaps we could alter this issue to focus on documentation? As you can see, this core once override, nuance seems like something that can confuse people. I just noticed the wealth of info in the /docs directory on both projects (I overlooked this before, just looking for a root readme). But I don't see this mentioned specifically in there.
Thank you again for the assistance. This was very helpful.