Skip to content

Feature: Storage.Watch(mustache, sector, callback) — typed data-change subscription for custom components #623

@silvath

Description

@silvath

Summary

Custom Drapo components written in TypeScript often maintain internal state objects that must stay in sync with Drapo's data store. Currently there is no clean API for a component to react when a specific data key (or field path) changes. This leads to fragile workarounds where components manually re-read from the data store at serialization time, risking stale state.


Problem

When a Drapo component is loaded, it may deserialize data from the store into a typed internal object (e.g. _process: WorkflowProcessVO). The component owns that internal object and uses it for all its business logic.

class WorkflowProcess {
    private _process: WorkflowProcessVO;

    public async Initialize(): Promise<void> {
        // Load from Drapo's store into internal object
        const raw: any = await this.Application.Storage.RetrieveData('workflowprocess', this.Sector);
        this._process = this.Serializer.Deserialize(raw);
    }
}

Later, the user interacts with a panel where bindings like d-model="{{workflowprocess.Parameters}}" update Drapo's store directly. At that point, _process.Parameters (the internal copy) is out of sync with what Drapo has stored.

When the component needs to save, it must manually sync the two copies:

// ⚠️ Current workaround
private async SaveInternal(): Promise<boolean> {
    const process: WorkflowProcessVO = this.Storage.GetProcessRoot(); // internal copy

    // Drapo stores a separate copy of workflowprocess, so parameters updated via
    // d-model bindings in the panel are not reflected in _process.Parameters.
    // Sync from the Drapo data key before serializing to capture any panel changes.
    const workflowProcessDrapo: any = await this.Application.Storage.RetrieveData('workflowprocess', this.Sector);
    if (workflowProcessDrapo?.Parameters != null)
        process.Parameters = workflowProcessDrapo.Parameters;

    const serialized: string = this.Serializer.Serialize(process);
    // ... POST to API
}

This works, but has drawbacks:

  • Fragile: any new field editable via Drapo bindings must be added to the sync block manually.
  • Imperative: the component must "remember" to sync before every serialization point.
  • Error-prone: if a future developer adds a new bindable field and forgets to sync it, data is silently lost on save.

Existing API — Observer.SubscribeComponent

Drapo already has an internal mechanism used by the built-in component system:

// From DrapoObserver.ts
public SubscribeComponent(value: string, el: HTMLElement, notifyFunction: Function, elFocus?: HTMLElement): void

An example from the labelinputsubscribe component:

this._app.Observer.SubscribeComponent(dataKey, this._el, async () => {
    await instance.Notify(); // must call RetrieveData() again inside Notify
});

This API partially addresses the problem, but has limitations for typed-state scenarios:

  1. Requires an HTMLElement as the observer identity — awkward for state sync that is not tied to a specific DOM element.
  2. Callback receives (el, app, dataFields), not the new value — the component still must call Storage.RetrieveData to get the updated data.
  3. Fires for the whole dataKey — if subscribed to workflowprocess, it fires on any change to the object, even unrelated fields.
  4. No unsubscribe token — there is no clean way for a component to stop listening (e.g. on destroy).

Proposed Solution

Add a higher-level API to DrapoStorage (or as a new method) designed specifically for component authors:

// Proposed signature
public Watch(mustache: string, sector: string, callback: (newValue: any) => Promise<void>): () => void

Usage from a component:

public async Initialize(): Promise<void> {
    const raw: any = await this.Application.Storage.RetrieveData('workflowprocess', this.Sector);
    this._process = this.Serializer.Deserialize(raw);

    // Subscribe — callback receives the new value automatically
    this._unwatch = this.Application.Storage.Watch(
        '{{workflowprocess.Parameters}}',
        this.Sector,
        async (newParameters: WorkflowProcessParameterVO[]) => {
            this._process.Parameters = newParameters;
        }
    );
}

public Destroy(): void {
    if (this._unwatch) this._unwatch();
}

Behavior

Concern Expected
mustache Any valid Drapo mustache path, e.g. {{key}} or {{key.field}}
sector The sector scope for resolving the data key
callback(newValue) Called whenever the referenced path changes; receives the current value
Return value An unsubscribe function; calling it stops all future notifications
Granularity Should fire only when the specific field path changes, not on all changes to the data key

Implementation Notes

Internally, Watch would reuse the existing Observer.SubscribeComponent infrastructure:

public Watch(mustache: string, sector: string, callback: (newValue: any) => Promise<void>): () => void {
    const sentinel: HTMLElement = document.createElement('span');
    this.Application.Observer.SubscribeComponent(mustache, sentinel, async (_el, _app, _fields) => {
        const newValue: any = await this.RetrieveDataValue(sector, mustache);
        await callback(newValue);
        return false; // prevent Drapo from calling ResolveComponentUpdate on the sentinel
    });
    return () => {
        // Requires UnsubscribeComponent to be made public — see secondary gap below
    };
}

This reveals a secondary gap: UnsubscribeComponent is currently private. To support a proper unsubscribe token, it (or a variant accepting an HTMLElement) should be made public.


Acceptance Criteria

  • Application.Storage.Watch(mustache, sector, callback) is available to custom component authors.
  • callback receives the new resolved value directly — no need to call RetrieveData inside the callback.
  • Watch returns an unsubscribe function.
  • Notification fires for field-level granularity when a path like {{key.field}} is given.
  • UnsubscribeComponent (or a public variant) is exposed so components can clean up subscriptions on destroy.
  • Documented with an example component.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions