WebNode Keyed Reconciliation and Scoped Pending UI Plan

Status: implemented through Phase 5; Phase 6 measurement remains follow-up

Primary use case: Nostr Radar (plugins/nr) Read, Archive/Unarchive, and local preference actions.

Related architecture:

Summary

Command-backed WebNode widgets currently refresh by replacing their complete WebNodeRoot. The replacement returns correct server state, but nested Solid components can be recreated because the new JSON contains new object identities. That resets local UI state such as open thread context and expanded post content.

NR temporarily avoided successful refreshes for Read by removing matching mounted DOM elements before the command completed. That made the clicked copy disappear quickly, but it introduced correctness problems:

The proposed architecture keeps server-rendered WebNodes and normal command refreshes, but changes how refreshed roots are applied:

  1. NR renders a complete authoritative list after a mutation.
  2. Every stateful or repeated WebNode has a stable renderKey.
  3. Every representation of one logical entity can share an entityKey.
  4. Core stores the rendered root in a keyed reactive Solid store.
  5. A refreshed root is reconciled into that store instead of replacing all node identities.
  6. Retained components receive reactive prop updates without remounting.
  7. Removed nodes unmount, new nodes mount, and unchanged stateful components keep their local signals.
  8. Core can display a scoped Updating... overlay for every mounted copy of the source entity while the command and its refresh run.

This is a complete server data refresh without a complete client component remount. It does not require a plugin-specific mutation DSL or executable client-side plugin code.

Goals

Non-Goals

The first version is a targeted pending experience followed by a fast, authoritative keyed reconciliation. True pre-success optimistic state can be added later if measured latency requires it.

Current Behavior and Failure Mode

Root replacement

WebNodeShadowRoot currently stores the root in a signal:

const [currentRoot, setCurrentRoot] = createSignal<WebNodeRoot>(props.root);

It assigns each incoming root directly:

createEffect(() => {
  setCurrentRoot(props.root);
});

WebNodeRenderer recursively renders element.children with Solid <For>. When a server refresh returns new JSON objects, child object references differ. Repeated elements can therefore be disposed and recreated even if they represent the same logical node.

Local state that can be reset

WebNostrPostElement owns local signals for:

WebTreeItemElement owns local expansion and lazy-loading state, with expansion also mirrored into the existing state-scope map.

Replacing component instances resets instance-local signals. The tree expansion map protects some tree state, but it does not protect Nostr post state.

Current Read workaround

NR adds this command metadata:

optimistic: {
  hideTargetIds: [`nr-event-${eventId}`],
  refreshOnError: true,
}

useCommands finds mounted elements in the widget ShadowRoot and executes:

element.remove();

Collapsed tree branches are conditionally rendered. Their event copies do not exist in the DOM at click time, so they are not removed. The underlying WebNode root also remains unchanged. Opening a different group can therefore mount the same event again.

Design Principles

The server remains authoritative

NR calculates:

The browser does not reproduce this business logic.

Core reconciles generic UI descriptions

Core understands only generic WebNode identity and command presentation:

Core does not know what an NR topic, mood, read marker, or preference means.

Reconcile the model, not the DOM

The refreshed WebNodeRoot is reconciled into a reactive WebNode store. Solid then applies DOM changes. Core must not use querySelectorAll(...).remove() as the source of truth for persistent command results.

Preserve stateful component identity

If a refreshed node has the same renderKey, node type, and element tag, its existing render record and component instance are retained. Its props, summary, and children update reactively.

If identity or element type changes, the old component is replaced.

Proposed Wire Contract

Stable render identity

Add optional renderKey to element nodes. It belongs to the wire node rather than presentation props because it controls client reconciliation.

Conceptual schema:

export const WebElementNodeSchema = z.object({
  type: z.literal('element'),
  tag: WebElementTagSchema,
  renderKey: z.string().min(1).optional(),
  props: WebElementPropsSchema.optional(),
  summary: WebNodeSchema.optional(),
  children: z.array(WebNodeSchema).optional(),
});

Text nodes do not need keys initially. Stateful and repeated element nodes do.

Requirements:

NR examples:

renderKey: `nr-section-topic`
renderKey: `nr-topic-${normalizedTopic}`
renderKey: `nr-topic-${normalizedTopic}-event-${event.id}`
renderKey: `nr-mood-${normalizedMood}-event-${event.id}`
renderKey: `nr-for-you-event-${event.id}`

The existing props.id remains the identifier used by tree expansion, stories, reveal actions, and DOM targeting. renderKey has one responsibility: keyed render reconciliation.

Shared entity identity

Add optional entityKey to WebBasePropsSchema:

entityKey: z.string().min(1).optional()

All representations of one event use:

entityKey: `nostr-event:${event.id}`

entityKey is intentionally not unique. It supports cross-representation client state such as pending presentation. It is not used as a list reconciliation key.

NR should put the entity key on the outer event treeItem. Descendants inherit the nearest entity context. This covers event overflow-menu actions and nostrPost action-row actions without duplicating metadata on every button.

Pending presentation

Add an optional generic command-action property:

pendingUi: {
  presentation: 'widget' | 'entity' | 'none';
  label?: string;
}

Compatibility behavior:

The overlay is entirely client-side. NR only chooses generic presentation on its action. Core creates, renders, and clears pending state before and after command execution.

NR mutations should initially use:

pendingUi: {
  presentation: 'entity',
  label: 'Updating...',
}

Client Reconciliation Architecture

Replace the root signal in WebNodeShadowRoot with a Solid store and apply Solid's reconcile utility, keyed by renderKey.

Conceptual direction:

const [currentRoot, setCurrentRoot] = createStore<WebNodeRoot>(props.root);

createEffect(() => {
  setCurrentRoot(
    reconcile(props.root, {
      key: 'renderKey',
      merge: true,
    }),
  );
});

This must be validated with the actual recursive WebNode schema before adoption. If Solid's stock reconciler cannot reliably handle optional keys, summaries, or mixed text/element arrays, add a focused reconcileWebNodeRoot helper that:

Do not build a separate generic patch language. The input to reconciliation is always the next complete WebNodeRoot.

Renderer reactivity

The retained WebNode object must be reactive. Components should read current props through accessors or reactive Solid store proxies.

For WebNostrPostElement, this existing shape is favorable:

const elementProps = () => props.element.props;

If props.element is a retained reactive proxy, memos and DOM expressions that read elementProps() can update while the component's local signals survive.

The renderer audit must identify eager one-time reads such as:

const disabled = element.props?.disabled === true;

Values that must change after reconciliation should become accessors or be read inside reactive JSX expressions.

Compatibility rules

The reconciler should apply these rules:

Previous node Next node Result
Same renderKey, type, and tag Updated props/children Retain instance and merge reactive data
Same key, different type or tag Incompatible Replace instance
Previous keyed node absent Removed Unmount instance
New keyed node absent previously Added Mount instance
Both nodes unkeyed Same array position and compatible shape Reconcile positionally
Unkeyed order changes Unknown identity Replacement is allowed

Stateful repeated plugin renderers must provide keys. Unkeyed fallback exists for backward compatibility, not as the recommended path.

Root metadata and stylesheets

Reconciliation must continue updating:

The existing effects in WebNodeShadowRoot should read the reactive store rather than a root signal. Stylesheet replacement behavior remains unchanged.

Scoped Pending Architecture

Source entity propagation

Add a generic entity context to web-node/contexts.ts:

export const WebEntityKeyContext = createContext<Accessor<string | null>>(
  () => null,
);

Each element renderer computes its effective entity key:

const effectiveEntityKey =
  element.props?.entityKey ?? parentEntityKey();

It provides that key to descendants. When an action runs, WebNodeRenderer passes the effective source key through RunWebActionParams:

webCommandSourceEntityKey: effectiveEntityKey

This source context is a core rendering concern. NR supplies only stable entity metadata in its WebNode output.

Pending state ownership

Pending state must be scoped by both widget source and entity:

Map<webCommandSourceId, Map<entityKey, number>>

Use reference counts rather than sets so concurrent operations cannot clear each other's pending state.

The existing socket/application state currently tracks widget busy counts by webCommandSourceId. Extend that state with:

beginWebEntityPending(sourceId, entityKey)
endWebEntityPending(sourceId, entityKey)
isWebEntityPendingFor(sourceId, entityKey)

The timeline card, modal, and dock pass an accessor into WebNodeShadowRoot. WebNodeShadowRoot provides a WebPendingEntityContext to descendants.

Nostr post presentation

WebNostrPostElement reads its effective entity key and pending state. While pending, it should:

The overlay belongs to the generic nostrPost client primitive and uses shared core CSS. NR does not provide markup or styles for it.

Other WebNode primitives may adopt entity pending presentation later. The first implementation only needs nostrPost because it is the concrete use case.

Pending lifecycle

For an action with entity presentation:

  1. Resolve webCommandSourceId and source entityKey.
  2. Increment entity pending before sending the socket request.
  3. Do not call beginWebUiBusy for the widget.
  4. Run the mutation command.
  5. Run its configured refresh command after success.
  6. Apply the refreshed root through normal host state.
  7. Reconcile the root inside WebNodeShadowRoot.
  8. Clear entity pending after the refresh result is accepted.
  9. On mutation failure, clear pending and leave the current root unchanged.
  10. On refresh failure after mutation success, clear pending, report the refresh error, and leave the current UI stale until retry/manual refresh.

The refresh must remain part of the pending lifecycle. Clearing pending when only the mutation command finishes would expose old controls before authoritative UI arrives.

Ownership Boundaries

NR owns

Core schema owns

Core client owns

WebTreeElement owns

It does not traverse the tree to apply NR business operations and does not calculate counts.

WebNostrPostElement owns

It does not call NR code or calculate persistent NR state.

NR Action Behavior After Migration

Read

NR action:

{
  type: 'command',
  command: alias,
  subcommand: 'mark',
  arguments: { event_id: event.id },
  options: { read: true },
  recordInTimeline: false,
  pendingUi: { presentation: 'entity', label: 'Updating...' },
  refresh: {
    command: alias,
    subcommand: 'list',
    arguments: {},
    options: { mode },
    recordInTimeline: false,
  },
}

Expected refreshed-tree differences:

Remove the current optimistic.hideTargetIds metadata and DOM removal path after the reconciled flow is verified.

Archive/Unarchive

The existing mutation and refresh shape remains. Add entity-scoped pending UI.

Expected refreshed-tree differences:

Thumbs-up/Thumbs-down

The existing interest-record command and list refresh remain. Add entity-scoped pending UI.

Expected refreshed-tree differences:

Empty Group Handling

NR already renders groups from current list data. After Read, a group with no remaining unread events should be omitted from the next root.

The reconciler sees that its group renderKey is absent and unmounts that treeItem. Retained sibling groups keep their instances even if their array positions change.

If an entire topic or mood section has no groups, retain the existing section empty-state behavior unless product requirements explicitly choose to remove the whole section. This is an NR renderer decision, not a reconciler rule.

Stale and Concurrent Refresh Protection

Several commands can overlap. A slower earlier refresh must not overwrite a newer root.

Add a monotonically increasing refresh generation per widget source:

Map<webCommandSourceId, number>

When dispatching a refresh:

  1. Increment and capture the source generation.
  2. Accept the refresh result only if its generation is still the latest started generation for that source.
  3. Ignore a late result from an older generation.
  4. Always clear only the pending reference owned by that request.

For the first NR release, disable repeated actions for a pending entity. This avoids archive/unarchive or preference races while retaining support for concurrent actions on different posts.

Detailed Implementation Phases

Phase 1: Reconciliation proof of concept

Files:

Tasks:

Exit criteria:

Phase 2: Reactive renderer audit

Files:

Tasks:

Exit criteria:

Phase 3: Generic entity identity and scoped pending state

Files:

Tasks:

Exit criteria:

Phase 4: NR stable identity migration

Files:

Tasks:

Exit criteria:

Phase 5: NR mutation migration

Files:

Tasks:

Exit criteria:

Phase 6: Hardening and broader adoption

Tasks:

Verification Plan

No implementation phase is complete until the following behavior is verified.

Reconciliation behavior

Read scenario

Initial state:

nostr (8)
  event A

personal (7)
  event A

Steps:

  1. Open thread context on event B.
  2. Click Read on event A under nostr.
  3. Confirm every visible event A copy shows Updating... while pending.
  4. Confirm there is no widget-wide Working overlay.
  5. Wait for refresh reconciliation.

Expected:

Archive scenario

Steps:

  1. Open a post's thread context.
  2. Archive it.

Expected:

Preference scenario

Steps:

  1. Select thumbs-up on an event represented in multiple groups.
  2. Remove thumbs-up.
  3. Select thumbs-down.

Expected:

Failure scenarios

Performance Considerations

Risks and Mitigations

Solid reconciliation does not preserve the expected component boundary

Mitigation:

Reactive prop updates are missed by eager reads

Mitigation:

Duplicate or unstable render keys

Mitigation:

Local state follows the wrong item after reorder

Mitigation:

Out-of-order refresh responses regress UI

Mitigation:

Entity pending state leaks

Mitigation:

Target post state resets despite reconciliation

Mitigation:

Rollout and Compatibility

Documentation Follow-Up

After implementation:

Final Architecture Decision

Proceed with server-authoritative full-root refresh plus client-side keyed reconciliation.

Implementation note: the final client uses a focused recursive WebNode reconciler. Solid's stock keyed reconciliation is not used for child arrays because optional keys and mixed text/element siblings make its array-key selection ambiguous.

Do not introduce:

The implementation should first prove that a retained keyed nostrPost receives new reactive props without losing local signals. That proof is the critical technical gate before migrating NR actions.