platform icon indicating copy to clipboard operation
platform copied to clipboard

Full-stack signals for Hilla

Open Legioth opened this issue 7 months ago • 0 comments

Description

Synchronize signals across the network to simplify building collaborative functionality based on sharing UI state between users or between the user and server-side logic.

Tier

Free

License

Apache 2.0

Motivation

Problem

Currently, the only mechanism for server-originated updates in Hilla is through a @BrowserCallable method returning Flux. An asynchronous stream of values can be used as a low-level mechanism for building almost anything but it's not convenient for many high-level cases such as updating an avatar group, discussing in a chat room, or editing a form draft together with a colleague.

  • A Flux uses the same data type for every message whereas something like an avatar group would need different messages for joining and leaving avatars rather than sending a new full list of all avatars for every update.
  • Special handling is needed to differentiate between the initial state and subsequent changes. This is the case even if always sending the full state since you also need to keep track of the last known full state and deliver it to any newly joined subscriber.
  • There's no way for the client to send updates to the server associated with a specific Flux. Instead, the receiving logic on the server needs to manually keep track of sink instances while taking care to avoid memory leaks.
  • The only simple approach for handling reconnects is to just re-fetch the full state again. Anything more efficient would require a significantly more complex design.
  • Doing latency compensation to immediately show feedback to the user would require custom state management.
  • Sharing updates across a cluster raises the complexity of all the previous challenges by a power of two.

All of these limitations can be dealt with using additional application code that requires effort to implement and clutters down the actual application logic.

Solution

Introduce a mechanism specifically for synchronizing complex UI state between the browser and the server. The same server-side state can be synchronized with multiple clients. This mechanism uses a paradigm of fine-grained signals (i.e. reactive value holders with automatic subscription and unsubscription) for representing the state. The existing integration between Preact Signals and React makes it trivial to update the UI based on changes to the UI state, regardless of whether the origin of the change is local or remote.

A "Hello world" example would be a button for incrementing a shared counter. Contrary to using a regular client-side signal, the full-stack signal API has a dedicated signal type for numbers to allow atomic arithmetic operations instead of overwriting the counter value with a potentially stale result.

@BrowserCallable
public class CounterService {
  private final NumberSignal counter = new NumberSignal(0);

  public NumberSignal getCounter() {
    return counter;
  }
}
const counter = CounterService.getCounter();

export default function CounterView() {
  return <Button onClick={() => counter.incrementBy(1)}>Click count: {counter}</Button>
}

A slightly more complex example that showcases additional functionality is for rendering an avatar group with deduplication, fine-grained access control, and automatically removing avatars of disconnected users.

const avatars = AvatarService.getAvatars();

const deduplicated = computed(() => {
  // The signal value is an array of signals holding avatar items
  const items = avatars.value.map((item) => item.value); // AvatarItem[]
  // The JS way of deduplicating based on item.id
  return [ ...new Map(items.map((item) => [item.id, item]).values() ];
});

const ownAvatar = {id: currentUserId, name: currentUserName, image: currentUserImage};

// connectionScoped adds the entry whenever the subscription
// is activated (a component using the data is attached and the user is online)
// and removes it when deatctivated (detach or offline)
avatars.insertLast(ownAvatar, {connectionScoped: true});

export default function AvatarView() {
  return <AvatarGroup items={deduplicated} />
}
record AvatarItem(String id, String name, String image) {}

@BrowserCallable
public class AvatarService {
  private final ListSignal<AvatarItem> avatars = new ListSignal<>();

  public ListSignal<AvatarItem> getAvatars() {
    UserDetails user = Helper.getCurrentUser();
 
    return avatars.withValidator(operation -> {
      // Real implementation should also block removals and edits based on ownership
      return user.getId().equals(operation.getItem().id() &&
        operation.getOptions().isConnectionScoped();
    });
  }
}

Note that the purpose of the example code above is only to describe some common usage patterns. It is not intended to define the exact API to implement.

Requirements

  • [ ] Signal types: APIs in both Java and TypeScript for signals with fine-grained operations for various basic use cases: a single atomic JSON value, arithmetic operations on a number, lists of uniformly typed values, maps of uniformly typed values, and arbitrary JSON-like tree structures. These signals give access to the full state (e.g. a list of child signals) that is based on the result of applying all received operations in a consistent order.

  • [ ] Endpoint methods: A full-stack signal instance can be returned from a @BrowserCallable method for which a corresponding TS API will be generated. Subscribing to a client-side signal acquired through that TS API will automatically set up synchronization with the corresponding server-side data and that synchronization will automatically be deactivated when the client-side signal no longer has any subscribers.

  • [ ] Thread safety: The Java implementation is thread-safe so that a shared signal instance can be used concurrently without synchronization in application code. The Java implementation automatically provides repeatable reads within a single Hilla endpoint method invocation.

  • [ ] Resynchronization: Re-subscribing to a client-side signal will automatically do an incremental catch-up with server-side state if possible. Changes applied to a signal instance while offline will be synchronized to the server when returning back online again if that signal instance is still in use at that time..

  • [ ] Latency compensation: A client-side full-stack signal can optimistically reflect the state as it would be after all pending changes have been processed on the server. It is possible to opt out from this latency compensation for specific changes and to explicitly read the state that is confirmed by the server regardless of any pending optimistic updates.

  • [ ] Transactions: It is possible to protect against concurrency conflicts by defining that a change should be applied only if certain conditions are met at the moment when the change is actually applied on the server. It is also possible to define a group of related changes as a transaction that will be applied atomically without any other changes interleaved.

  • [ ] Filtering: A signal instance in Java can be wrapped with a filter that will validate all changes applied through the wrapped signal instance.

  • [ ] Connection scope: Entries in a list, map or tree structure can be marked to be owned by a specific client. Such entries are automatically deleted when that client no longer subscribes to the signal even in the case when the client loses network connectivity and is implicitly unsubscribed due to a timeout. The entry is added back again if there are again subscribers and network connectivity is restored.

  • [ ] Clustering: Server-side signal instances can be backed by an event log that is shared across a server cluster. The shared event log implementation is pluggable and there's a single built-in implementation based on PostgreSQL that stores all events in a single database table and uses LISTEN/NOTIFY to notify application servers about updates.

  • [ ] Documentation

  • [ ] Remove the feature flag

Nice-to-haves

  • [ ] A sub signal that reflects whether there are currently any pending changes that have not yet been confirmed.
  • [ ] Define that some parts of a transaction should be applied even if other parts are rejected due to a conflict

Risks, limitations and breaking changes

Risks

None

Limitations

None

Breaking changes

There may be breaking changes to the API that is currently implemented behind a feature flag.

Out of scope

This PRD only defines a minimal set of core features. There is lots of functionality that we should consider separately building on top of the core functionality (not listed in any particular order).

  • Models: We could generate TS that provides type-safe fine-grained access based on an existing Java type to a full-stack signal that is expressed as an arbitrary JSON-like tree. This basically means that you could use a hierarchy of signals (local and/or full-stack) as a view model from the MVVM pattern.
  • Full offline support: This PRD defines that an existing client-side signal instance can be efficiently re-synchronized to server-side data after being temporarily disconnected. That functionality only covers cases where the TypeScript instance is retained. Full offline synchronization would additionally require a way of storing the necessary underlying event data in e.g. localStorage so that an efficient catch-up could be performed even if the browser tab has been closed and opened again while disconnected.
  • Copilot integration: There might be opportunities to integrate the signal core with Copilot to e.g. allow inspecting the current state of a signal or to see which signals are currently synchronized.
  • Additional cluster backends: We will not provide built-in support for other clustering technologies such as Kafka, Redis or Hazelcast within the scope of this PRD.
  • Case-specific signal wrappers: As shown in the <AvatarGroup> example, there's an opportunity for providing built-in signal types for the most common collaboration use cases such as those supported in Collaboration Kit (presence, chat, editing a form together).
  • CRUD signals: The signal concept could also be used for lazy loading and filtering data from a database to provide live updating for other users when data is modified through the signal.

Materials

No response

Metrics

Reported to UsageStatistics if the application has at least one endpoint method that returns a signal type.

Pre-implementation checklist

  • [ ] Estimated (estimate entered into Estimate custom field)
  • [ ] Product Manager sign-off
  • [ ] Engineering Manager sign-off

Pre-release checklist

  • [ ] Documented (link to documentation provided in sub-issue or comment)
  • [ ] UX/DX tests conducted and blockers addressed
  • [ ] Approved for release by Product Manager

Security review

None

Legioth avatar Mar 31 '25 07:03 Legioth