Skip to main content
Light Dark System

Lazy Load Controller

Et2LazyLoadController Controller

Overview

Lets a widget hold off on work that isn’t worth doing yet - typically because nobody can see the result, but isExtraReady can add any other condition (e.g. “the parent widget that owns our data has finished its own load”) on top of that.

Whether a widget is displayed is not something it can tell from its own attributes - a display: none several DOM levels (and shadow roots) up hides it just as well, and the widget must not have to know about whatever app-specific state switched that on (a details toggle, an inactive tab, …). ready answers that from the element itself: it covers display: none on the element or any of its ancestors, and being detached. It deliberately ignores the viewport: something displayed but scrolled away counts as ready, same as a widget with no visibility concept at all.

onReady is the missing event for the visibility half: an element with no box never intersects anything, so an IntersectionObserver reports getting a box - by being displayed again, or by being scrolled into view - as becoming ready. There’s no such event for isExtraReady, since this controller has no way to know what it depends on; call recheck() when that condition changes (e.g. from whatever “other load” you were waiting on, once it finishes).

onReady is called every time ready is observed becoming true - including more than once, if the host is hidden and shown again, or recheck() is called again after isExtraReady has already been satisfied once. Make it idempotent (as Et2LinkString._loadDeferred() is: a no-op once there’s nothing pending) rather than relying on it firing exactly once.

Sometimes the deferral itself needs to be overridden - a print view needs every row’s content whether or not it was ever scrolled into view, a user action can ask for a hidden tab’s data right now instead of waiting for the tab to become active. force() just runs onReady right away, gates or no gates - it doesn’t change what ready reports, so a caller whose onReady re-checks ready itself (as Et2LinkString._loadDeferred() does, since it can also be reached from a plain re-render - see its updated()) needs to do that check with the same bypass in mind if it wants force() to actually go through.

Nothing is remembered across a disconnect, so a host that gets detached, re-attached or re-used for other content (as happens when a virtualized list recycles a row widget) needs no extra care from the caller.

whenReady is the same event as onReady, as a Promise instead of a callback - for code that wants to await the gates once rather than react to them every time they open. It resolves the moment onReady would be called - which, since onReady fires on every open (see above), can already be in the past: if ready is already true when something reads whenReady, it resolves right away instead of waiting for the next open, which may never come. It settles once and is done; a caller that expects to wait again later (e.g. after the host is hidden and shown again) reads it again rather than holding on to the first Promise.

What this is for

Some widgets kick off work - typically a server request - as soon as they’re given something to work with, whether or not anyone can currently see the result. Inside a virtualized list that’s a widget per row, so it’s a request per row, all at once, most of them for rows nobody has scrolled to yet. Et2LazyLoadController lets a widget hold that work until it’s actually worth doing: the host is connected and not hidden by CSS anywhere up the tree, and, if you gave it one, some other condition of your choosing.

It doesn’t touch rendering. It only decides when to call a function you give it.

Basic use

export class MyWidget extends Et2Widget(LitElement)
{
	protected _lazyLoad = new Et2LazyLoadController(this, () => this._fetchData());

	set entryId(value)
	{
		this._entryId = value;
		if(!this._lazyLoad.ready)
		{
			// Nobody can see the answer yet - _fetchData() runs again once we're ready
			return;
		}
		this._fetchData();
	}

	protected _fetchData()
	{
		// ... send the request, update this.data, requestUpdate() ...
	}
}

onReady (the second constructor argument) can fire more than once - the host can be hidden and shown again, or you can call recheck() / force() yourself - so write it to be safe to call when there’s nothing to do, the same way _fetchData() above would just be a wasted call if entryId hadn’t actually changed since the last successful fetch. Et2LinkString’s get_links() / _loadDeferred() (api/js/etemplate/Et2Link/Et2LinkString.ts) is a real, working example of this pattern, including how to stay correct when the host gets recycled for a different entry while a request is still in flight.

Waiting for something else too

Pass a third argument to add a condition on top of visibility - e.g. “don’t fetch until the parent widget has finished loading”:

protected _lazyLoad = new Et2LazyLoadController(
	this,
	() => this._fetchData(),
	() => this.parentWidget.loaded
);

There’s no event for that condition - this controller has no way to know what it depends on - so call recheck() from whatever code changes it:

parentWidgetFinishedLoading()
{
	this.loaded = true;
	this.childWidgets.forEach(child => child._lazyLoad.recheck());
}

Forcing it

force() runs onReady right away, gates or no gates - for something like a print view that needs every row’s content whether or not it was ever scrolled into view. It’s a one-off bypass, not a standing override: ready still reports the real state afterward, so if your onReady re-checks ready itself before doing anything (as _loadDeferred() does), make sure that check accounts for being forced.

Waiting on it instead of reacting to it

whenReady is the same event as onReady, as a Promise - useful for code that wants to await the gates once instead of supplying a callback:

async printPreview()
{
	await this._lazyLoad.whenReady;
	// definitely ready now, whether it already was or we just waited for it
	return this._fetchData();
}

It resolves the moment onReady would be called, including via recheck() or force(). Read it again for each new wait - it settles once, so it won’t tell you about the next time the host goes hidden and becomes ready again.

Et2LazyLoadController vs. until()

Lit’s until() directive and this controller can look like they solve the same problem - both show a placeholder while something loads - but they’re deferring different things.

until() defers what gets rendered. The promise it’s waiting on is already running by the time until() sees it - until() just chooses what to put in the DOM until it resolves:

render()
{
	// This request starts the moment render() runs - whether or not this widget, or the
	// row it's in, is even visible. until() only controls what's shown while it's pending.
	return html`${until(this._fetchData(), this._loadingTemplate())}`;
}

Et2LazyLoadController defers starting the work at all. Nothing is requested until ready is true - a hidden row costs nothing, not even a request that gets thrown away:

render()
{
	// Nothing to await here - _fetchData() populates this.data and calls
	// requestUpdate() itself, once the controller decides it's worth running
	return html`${this.data ? this._dataTemplate() : this._loadingTemplate()}`;
}

They compose fine: use Et2LazyLoadController to decide when to kick off the request, and until() (or a Lit @state + plain conditional, as above) to decide what to show while that request - once it’s actually running - is in flight. What you don’t want is until() on a promise that already started unconditionally; that’s the exact cost this controller exists to avoid.

Used by

These widgets create and host this controller.

Properties

Name Description Type Default
ready Is the host worth doing deferred work for right now? boolean -
whenReady The same event as onReady, as a Promise - see the class doc for what “the same event” means when onReady can fire more than once. Promise -

Methods

Name Description Arguments
force() Run onReady right away, regardless of visibility or isExtraReady - a one-off bypass, not a change to what ready reports afterward. -
recheck() Re-check readiness for whatever isExtraReady depends on - the visibility half has its own IntersectionObserver and doesn’t need this. Call it from the code that owns the condition isExtraReady checks, once that condition changes. -