Suspense and Advanced Binding

Previously in the Getting Started tour, we discussed Component Children and Children Binding. With a handle on several types of binding by this point, we should be able to dig into some of the meatier aspects of Butterfloat's binding mechanics and some of its advanced features, especially Suspense binding.

Default Scheduling (Delayed Scheduling) versus Immediate Scheduling

There are two common "flavors" of binds in Butterfloat: delayed (which is largely the default) and immediate. The primary places you see these flavors are in the JSX bind versus immediateBind attributes, classBind versus immediateClassBind, styleBind versus immediateStyleBind, and in the ComponentContext the differences between bindEffect and bindImmediateEffect helpers.

The immediate flavor gets the longer names because it shouldn't be the default. The delayed scheduling flavor tries to be smarter by default.

There's one obvious exception to note that fields named value are always immediately bound even when in the bind default attribute. This is because the way value fields usually imply a user input space where delays would be most noticeable to users and most detrimental to user interaction. (Though the advice here is that you should consider twice if you need to bind an input's value.)

If you do want to opt-in to a delay scheduled value field, such as for elements that use value outside of direct user interaction such as <progress /> elements, or in cases where you have tested and are careful of the user interaction repercussions, you can use the bfDelayValue special key to the default bind attribute.

Delayed scheduling exists to orchestrate bound changes to hopefully maximize responsiveness of the application. The general basics are that changes are aggregated and buffered so that they happen no faster than requestAnimationFrame time. This allows the browser to slow things down in the case of heavy repaints or backgrounded windows or other user interaction priorities. This should reduce noticeable "churn" of changes from a user's perspective (very basic debouncing/throttling), and increase the priorities of user interaction responsiveness.

It is suggested to start with delayed scheduling as your default and then as a developer where you learn that some updates provide a better user experience when immediately bound, you can move those bindings to the appropriate immediateBind or bindImmediateEffect.

(One such case for bindImmediateEffect over bindEffect is for events where you need to prevent the default action, such as binding effects for events to <a href="#" events={{ click }} /> or <form method="POST" action="/fallback/address" events={{ submit }} />.)

Suspense

Suspense is an optional, added layer on top of the default scheduling that further buffers changes based on a developer-provided Observable<boolean>.

To configure suspense, you use the Suspense system-provided component:

import { Children, type ComponentContext, Suspense, jsx } from 'butterfloat'
import type { Observable } from 'rxjs'

export interface LoadableVm {
  loading: Observable<boolean>
  load(): void
}

interface LoadViewModelProps {
  vm: LoadableVm
}

export function LoadViewModel(
  { vm }: LoadViewModelProps,
  context: ComponentContext,
) {
  vm.load()
  return (
    <Suspense when={vm.loading}>
      <Children context={context} />
    </Suspense>
  )
}

When the provided observable is true (in this example, while the LoadableVm is loading), all default-scheduled bindings for the entire tree below the Suspense component are buffered but not applied. Once the observable is false again, the buffered changes will be applied.

Suspense can be a powerful tool to avoid UI bouncing and churn and sometimes costly repaints while a complicated amount of state is being loaded or calculated or otherwise complexly and deeply changed or reconfigured.

Note that Suspense cannot and will not interfere with immediate bindings and you will need to find your own way to throttle or suspend your immediate changes to the DOM tree.

There's one other optional feature, the Suspense object can take an optional suspenseView component to be swapped into place while the suspense when observable is true:

function LoadViewModel({ vm }: LoadViewModelProps, context: ComponentContext) {
  vm.load()
  return (
    <Suspense
      when={vm.loading}
      suspenseView={() => (
        <p>
          Loading… <progress />
        </p>
      )}
    >
      <Children context={context} />
    </Suspense>
  )
}

Note that both components and their bindings are live at the same time, but which one is a child in the tree is swapped/replaced. A performance consideration to keep in mind is to generally avoid bindings in a suspenseView, the more static HTML it is the likely it will perform better. Also, again, you may want to keep in mind that your immediate bindings in both trees are unaffected by any suspense changes.

bfDomAttach Event

As a last resort you may wish to manage your own bindings directly to the HTMLElement in the DOM that your JSX represents. This can be useful for bridging to classic Vanilla JS components, for instance. To provide for this need, you can bind to the bfDomAttach event on a static element.

import { type ComponentContext, jsx } from 'butterfloat'
import { type Observable, shareReplay, switchMap } from 'rxjs'

interface SomeVanillaComponent {
  destroy(): void
}

interface SomeVanillaComponentFactory {
  render(element: HTMLElement): SomeVanillaComponent
}

interface VanillaWrapperProps {
  vanillaFactory: SomeVanillaComponentFactory
}

interface VanillaWrapperEvents {
  attach: ObservableEvent<HTMLElement>
}

function VanillaWrapper(
  { vanillaFactory }: VanillaWrapperProps,
  { bindEffect, events }: ComponentContext<VanillaWrapperEvents>,
) {
  const wrappedVanilla = events.attach.pipe(
    switchMap(
      (element) =>
        new Observable<SomeVanillaComponent>((subscriber) => {
          const component = vanillaFactory.render(element)
          subscriber.next(component)
          return () => component.destroy()
        }),
    ),
    shareReplay(1),
  )

  bindEffect(wrappedVanilla, () => {})

  return <div events={{ bfDomAttach: events.attach }} />
}

One useful thing to note here is that we are handling the vanilla component's full lifetime in the Observable through the use of switchMap and making sure that we return the teardown logic of it in the new Observable constructor. In general, Butterfloat suggests using Observable lifetimes for handling resource cleanup.

bindEffect forces that observable to be eventually subscribed, but if you had other bindings that depended on that wrappedVanilla component you may not that empty bindEffect at all.

Static attachment

As a final bypass and alternative to innerHTML JSX attributes, you can use the <Static element={staticDomElement} /> pseudo-component. Static does no lifetime handling and provides no teardown lifecycle events. The DOM elements attached this way should be truly static (no event handlers, as a big for instance).

Static can be considered an escape hatch for things such as SVG and MathML and vanilla JS template systems that populate their own DOM elements.

Completion versus Removal

In Butterfloat, when a binding completes it signals completion for the entire component. By default when a component completes it is removed at that time.

This behavior may be surprising the first time it is encountered, but it is the behavior of least surprise in the long term. Rather than a component left in a dead, static, or only partially working state, it is removed as it seems to have finished its job.

Butterfloat includes some console.debug output to help debug which type of binding may have completed. It is also suggested to try using rxjs-spy to instrument your observables in debug builds. That can be a very useful debugging tool in general for Butterfloat applications.

(You may also want to configure your production bundler, if you use one, to trim `console.debug`` lines from production builds if it doesn’t already. Though these completion logs should be rare in any case and many browsers ignore them when development consoles are not open.)

As a last resort to help in complicated debugging scenarios, and as a building block to potential future features, you can force the RuntimeOptions to preserve completed components in the DOM tree by setting the preserveOnComplete flag to true.

preserveOnComplete probably isn’t the behavior you want in a production app, but partly exists because of proposed plans for static site generation (SSG), server-side rendering (SSR), and progressive enhancement scenarios where the completion versus removal relationship flips. In some of those cases you only want to serialize components that completed in server time and remove unfinished components to rerun on client side.

Expect things to get more complicated in the future if such features are added.

Error Boundary

The ErrorBoundary system-provided component can be used to catch errors in any of the components below it in the tree and provide an error view when that happens.

The ErrorBoundary also lets you set the preserveOnComplete flag for debugging the static parts of the DOM left behind after error states.

In the case of ErrorBoundary your error view component may be enough to explain leftover "dead" components in your user experience, so using an ErrorBoundary to set a preserveOnComplete may make sense in some production builds, though keep in mind that other completions that are not errors will not present your error view.

Next Steps

Stamps can be a useful building block for advanced optimization like server-side rendering and other progressive enhancement scenarios.