UNPKG

@knyt/luthier

Version:

A library for building standardized, type-safe native web components with full SSR and hydration support.

203 lines (202 loc) 7.56 kB
import { debounce, isPromiseLike, isServerSide, ref, SubscriptionRegistry, } from "@knyt/artisan"; import { hold, untrack, } from "@knyt/tasker"; import { DeferredContentNotifier } from "./DeferredContentNotifier"; import { SkipRenderSignal } from "./SkipRenderSignal"; /** * @internal scope: module */ // NOTE: This only exported for typing purposes. export class DeferredContentRenderer { #host; #data$; #references; #notifier; #isConnected = false; #activePromises = new Set(); #subscriptions; constructor(host, references) { this.#host = host; this.#references = references; // Operation that need to be torn down in the `destroy()` method { this.#notifier = new DeferredContentNotifier(host); this.#data$ = hold(host, undefined); host.addController(this); } } #getCurrentPromises() { return this.#references.map( // Do not remove undefined promises here, // as maintaining the order of references is important. // Instead, replace undefined promises with a resolved promise. (r) => r.value ?? Promise.resolve(undefined)); } #handleRejection(error) { // Unable to recover from a rejected promise. // // The error is logged and the data is not updated. This approach // encourages consistent error handling outside of deferred content. // // Handling errors within deferred content can reduce encapsulation // and complicate application logic. For clarity and maintainability, // please manage side effects and error handling separately from // content rendering. // // TODO: Use a shorter message in production builds. // TODO: Add a link to documentation about handling rejected promises. console.error([ "Knyt: An error occurred while resolving deferred content ", "promises. Please note that Knyt does not handle rejected ", "promises within deferred content. To ensure proper behavior, ", "please handle promise rejections outside of deferred content.", ].join("\n"), error); } async #getData() { try { const values = await Promise.all(this.#getCurrentPromises()); if (values.includes(SkipRenderSignal)) { return undefined; } return values; } catch (error) { this.#handleRejection(error); return undefined; } } async #updateData() { const currentPromises = this.#getCurrentPromises(); for (const promise of currentPromises) { this.#activePromises.add(promise); this.#notifier.registerPromise(promise); } try { const values = await Promise.all(currentPromises); if (values.includes(SkipRenderSignal)) { return; } for (const promise of currentPromises) { this.#activePromises.delete(promise); } this.#data$.set(values); } catch (error) { this.#handleRejection(error); } } /** * An observer that reacts to changes in any of the promise references. * * @detachable */ #promiseObserver = { next: debounce((currentPromise) => { // If the reference changes, re-fetch the data. // `updateData` will ensure that only the latest // promises are tracked. if (this.#isConnected) { this.#updateData(); } }, debounce.Wait.Microtask), }; /** * @internal scope: module */ hostConnected() { this.#isConnected = true; // Subscribe to reference changes. Subscribing to each reference // will immediately trigger an update, so there is no need // to call `updateData` separately. this.#subscriptions = new SubscriptionRegistry(this.#references.map((reference) => reference.subscribe(this.#promiseObserver))); } /** * @internal scope: module */ hostDisconnected() { this.#isConnected = false; // Clear the active promises, as they are no longer relevant. this.#activePromises.clear(); this.#subscriptions?.unsubscribeAll(); for (const promise of this.#activePromises) { this.#notifier.unregisterPromise(promise); } } /** * Creates a render function that defers rendering while any of the * promises of the previously given references are unresolved. * * @remarks * * Whenever any of the references change, the rendering will be deferred again * until all new promises have resolved. * * While the rendering is deferred, a `DeferredContent` component higher in the * DOM tree may display a placeholder UI while loading is in progress. * * When all promises have resolved, the provided render function is called * with the resolved values, and its result is rendered. However, if any other * elements are being deferred under the `DeferredContent` element, the * placeholder will continue to be shown until all deferred content is ready. */ thenRender(renderFn) { const renderWithData = (data) => { // Do not check `isLoading` state from the notifier, because // it may still be loading promises from other elements. // This render should only concern itself with its own data. return data ? renderFn(...data) : null; }; if (isServerSide()) { // On the server, we want to wait for all promises to resolve // before rendering the content. This ensures that the initial // HTML sent to the client is fully rendered with all data. return async () => { return renderWithData(await this.#getData()); }; } return () => { // On the client, we can render immediately with the // current data, which may be undefined if the promises // have not yet resolved. return renderWithData(this.#data$.value); }; } /** * Cleans up resources used by the controller and removes it from the host. */ destroy() { this.#host.removeController(this); untrack(this.#host, this.#data$); this.hostDisconnected(); this.#notifier.destroy(); } } /** * Defers rendering of content until the provided promise has resolved. * * @remarks * * After the promise resolves, the underlying controller is destroyed, * removing itself from the host, and any associated resources are cleaned up. */ function deferContentPromise(host, promise) { const renderer = new DeferredContentRenderer(host, [ ref(promise).asReadonly(), ]); promise.finally(() => { renderer.destroy(); }); } /* * ### Private Remarks * * After the promise resolves, the underlying controller is destroyed, * removing itself from the host, and any associated resources are cleaned up. */ export function defer(host, promiseOrReference, ...otherReferences) { if (isPromiseLike(promiseOrReference)) { deferContentPromise(host, promiseOrReference); return; } const references = [promiseOrReference, ...otherReferences]; return new DeferredContentRenderer(host, references); }