# profile-builder/4.0.0/form-builder/blocks/src/lib/persistFieldAttributes.js

User Profile Builder – Beautiful User Registration Forms, User Profiles &amp; User Role Editor, version 4.0.0. 271 lines.

- Page: https://pluginprobe.com/plugins/profile-builder/4.0.0/code/form-builder/blocks/src/lib/persistFieldAttributes.js
- Raw: https://pluginprobe.com/plugins/profile-builder/4.0.0/raw/form-builder/blocks/src/lib/persistFieldAttributes.js
- Modified: 2026-08-17T08:51:38+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/profile-builder/4.0.0/code/form-builder/blocks/src/lib/persistFieldAttributes.js#L10-L20`.

```javascript
/**
 * Shared per-edit attribute persister for canvas + VirtualFieldEdit.
 * Identity is `(scope, id)` — top vs Repeater sub scopes never share timers.
 * Diff is against full `lastSent`, not the previous patch.
 */

import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';

const DEBOUNCE_MS = 500;
const MAX_PUT_RETRIES = 5;

// Don't echo `conditional-logic` write-backs — server prunes empty rules and
// that would wipe a half-filled rule the user is still editing.
const WRITE_BACK_EXCLUDED_KEYS = new Set( [ 'conditional-logic' ] );

const debounceTimers = new Map();   // key → timeout handle
const lastSent       = new Map();   // key → { ...full attribute snapshot }
const pendingPatches = new Map();   // key → accumulated patch awaiting the next debounce flush
const retryCounts    = new Map();   // key → consecutive failed-PUT attempts
const pendingMeta    = new Map();   // key → { scope, id, fieldType, clientId } for an unload flush

/** Surface a persist failure (VirtualFieldEdit has no form-save fallback). */
function noticePersistFailure() {
    const notices = dispatch( 'core/notices' );
    if ( ! notices || typeof notices.createErrorNotice !== 'function' ) return;
    notices.createErrorNotice(
        __(
            'Profile Builder could not save one or more field settings. Check your connection, then re-apply the change or save the form to retry.',
            'profile-builder'
        ),
        { id: 'wppb-fb-persist-failed', isDismissible: true }
    );
}

/** Flush queued patches on pagehide (debounce would lose them on navigate). */
export function flushAllPendingPersists() {
    for ( const key of Array.from( pendingPatches.keys() ) ) {
        const patch = pendingPatches.get( key );
        const meta  = pendingMeta.get( key );
        pendingPatches.delete( key );
        if ( debounceTimers.has( key ) ) {
            clearTimeout( debounceTimers.get( key ) );
            debounceTimers.delete( key );
        }
        if ( ! meta || ! patch || Object.keys( patch ).length === 0 ) continue;
        flushPut( { ...meta, patch }, true );
    }
}

let unloadFlushBound = false;
function bindUnloadFlush() {
    if ( unloadFlushBound || typeof window === 'undefined' ) return;
    unloadFlushBound = true;
    window.addEventListener( 'pagehide', flushAllPendingPersists );
}

let snapshotSync = null;
export function registerSnapshotSync( fn ) {
    snapshotSync = fn;
    return () => { if ( snapshotSync === fn ) snapshotSync = null; };
}

const writeBackHandlers = new Map();
export function registerWriteBack( clientId, fn ) {
    writeBackHandlers.set( clientId, fn );
    return () => { writeBackHandlers.delete( clientId ); };
}

function keyFor( scope, id ) {
    return scope + ':' + id;
}

/**
 * Capture the initial attribute state of a block without scheduling a PUT.
 * Called by the mirror's first walk after editor mount so the diff baseline
 * matches what the server already has.
 */
export function captureInitial( scope, id, attributes ) {
    lastSent.set( keyFor( scope, id ), { ...attributes } );
}

/**
 * Forget the cached state for a key. Called when a block is removed from
 * canvas so its identity can be reused without false-positive diffs if the
 * id is later assigned to a different block.
 */
export function forget( scope, id ) {
    const key = keyFor( scope, id );
    if ( debounceTimers.has( key ) ) {
        clearTimeout( debounceTimers.get( key ) );
        debounceTimers.delete( key );
    }
    lastSent.delete( key );
    pendingPatches.delete( key );
    retryCounts.delete( key );
    pendingMeta.delete( key );
}

/** Arm debounce/retry timer that drains pendingPatches into flushPut. */
function armFlushTimer( key, meta, delay ) {
    bindUnloadFlush();
    pendingMeta.set( key, { scope: meta.scope, id: meta.id, fieldType: meta.fieldType, clientId: meta.clientId } );
    if ( debounceTimers.has( key ) ) clearTimeout( debounceTimers.get( key ) );
    debounceTimers.set( key, setTimeout( () => {
        debounceTimers.delete( key );
        const flushPatch = pendingPatches.get( key );
        pendingPatches.delete( key );
        if ( ! flushPatch || Object.keys( flushPatch ).length === 0 ) return;
        flushPut( { ...meta, patch: flushPatch } );
    }, delay ) );
}

/**
 * Move persister state when a key migrates (Repeater meta-name rename).
 * Do not `forget` — that drops pending patches and resets the baseline.
 */
export function migrate( fromScope, fromId, toMeta ) {
    const fromKey = keyFor( fromScope, fromId );
    const toKey   = keyFor( toMeta.scope, toMeta.id );
    if ( fromKey === toKey ) return;

    if ( lastSent.has( fromKey ) ) {
        lastSent.set( toKey, lastSent.get( fromKey ) );
        lastSent.delete( fromKey );
    }
    if ( pendingPatches.has( fromKey ) ) {
        const carried  = pendingPatches.get( fromKey );
        const existing = pendingPatches.get( toKey ) || {};
        pendingPatches.set( toKey, { ...carried, ...existing } );
        pendingPatches.delete( fromKey );
    }
    if ( retryCounts.has( fromKey ) ) {
        retryCounts.set( toKey, retryCounts.get( fromKey ) );
        retryCounts.delete( fromKey );
    }
    if ( debounceTimers.has( fromKey ) ) {
        clearTimeout( debounceTimers.get( fromKey ) );
        debounceTimers.delete( fromKey );
    }

    const pend = pendingPatches.get( toKey );
    if ( pend && Object.keys( pend ).length > 0 ) {
        armFlushTimer( toKey, toMeta, DEBOUNCE_MS );
    }
}

/** Schedule a debounced REST PUT for an attribute change. */
export function scheduleAttributePersist( { scope, id, fieldType, clientId, attributes } ) {
    if ( ! id || id <= 0 ) return;
    if ( ! scope || ! attributes ) return;

    const key  = keyFor( scope, id );
    const prev = lastSent.get( key );

    // First time we see this id at this scope: capture baseline, no PUT.
    if ( ! prev ) {
        lastSent.set( key, { ...attributes } );
        return;
    }

    // Compute the patch.
    const patch = {};
    for ( const k of Object.keys( attributes ) ) {
        if ( k === 'id' || k === 'field' ) continue;
        if ( attributes[ k ] !== prev[ k ] ) {
            patch[ k ] = attributes[ k ];
        }
    }
    if ( Object.keys( patch ).length === 0 ) return;

    // Advance lastSent so a fast follow-up diffs against the in-flight value.
    lastSent.set( key, { ...prev, ...patch } );

    // Merge into pendingPatches; timer flush sends one PUT.
    const pending = pendingPatches.get( key ) || {};
    Object.assign( pending, patch );
    pendingPatches.set( key, pending );

    // A fresh edit means we're out of failure-retry backoff — full budget again.
    retryCounts.delete( key );

    armFlushTimer( key, { scope, id, fieldType, clientId }, DEBOUNCE_MS );
}

function flushPut( { scope, id, fieldType, clientId, patch }, keepalive = false ) {
    const key  = keyFor( scope, id );
    const path = scope === 'top'
        ? `/wppb/v1/existing-fields/${ id }`
        : `/wppb/v1/existing-fields/sub/${ encodeURIComponent( scope.slice( 4 ) ) }/${ id }`;

    const body = { attributes: patch };
    if ( fieldType ) body.field = fieldType;

    const request = { path, method: 'PUT', data: body };
    // Unload flush: apiFetch forwards unknown options to window.fetch, so this
    // lets the request survive the document being torn down.
    if ( keepalive ) request.keepalive = true;

    apiFetch( request )
        .then( ( response ) => {
            if ( ! response || ! response.field ) return;

            const canonical = response.field.attributes || {};
            const sentPatch = patch;

            // Write back only keys the server changed that the user hasn't typed past.
            const sanitizedDiff = {};
            for ( const k of Object.keys( sentPatch ) ) {
                if ( WRITE_BACK_EXCLUDED_KEYS.has( k ) ) continue;
                if ( canonical[ k ] !== undefined && canonical[ k ] !== sentPatch[ k ] ) {
                    sanitizedDiff[ k ] = canonical[ k ];
                }
            }
            // Also surface meta-name even when not in the sent patch — the
            // server might have auto-generated it on first upsert.
            if (
                response.field.metaName !== undefined &&
                sentPatch[ 'meta-name' ] === undefined &&
                ( ! lastSent.get( key ) || lastSent.get( key )[ 'meta-name' ] !== response.field.metaName )
            ) {
                sanitizedDiff[ 'meta-name' ] = response.field.metaName;
            }

            // This PUT succeeded, so clear any failure-retry state for the key.
            retryCounts.delete( key );

            // Advance lastSent to the server's canonical state for every
            // key it touched. Keeps future diffs honest.
            const merged = { ...( lastSent.get( key ) || {} ), ...canonical };
            if ( response.field.metaName !== undefined ) merged[ 'meta-name' ] = response.field.metaName;
            // Re-queue edits that landed during the in-flight PUT.
            const pendingNow = pendingPatches.get( key );
            if ( pendingNow ) Object.assign( merged, pendingNow );
            lastSent.set( key, merged );

            if ( Object.keys( sanitizedDiff ).length > 0 ) {
                const writeBack = writeBackHandlers.get( clientId );
                if ( writeBack ) writeBack( sanitizedDiff, sentPatch );
            }

            if ( snapshotSync ) snapshotSync( response.field, scope );
        } )
        .catch( ( err ) => {
            // eslint-disable-next-line no-console
            console.warn( '[wppb-fb] attribute persist failed', { path, patch, err } );

            // On failure, roll optimistic lastSent back for the failed keys.
            const attempts = ( retryCounts.get( key ) || 0 ) + 1;
            if ( attempts > MAX_PUT_RETRIES ) {
                // Give up after retries: restore lastSent for failed keys and notice.
                const baseline = lastSent.get( key );
                if ( baseline ) {
                    for ( const k of Object.keys( patch ) ) delete baseline[ k ];
                    lastSent.set( key, baseline );
                }
                retryCounts.delete( key );
                noticePersistFailure();
                return;
            }
            retryCounts.set( key, attempts );

            // Merge the failed patch UNDER anything queued since (a newer edit's
            // value for the same key must win), then re-arm.
            const requeued = pendingPatches.get( key ) || {};
            pendingPatches.set( key, { ...patch, ...requeued } );
            armFlushTimer( key, { scope, id, fieldType, clientId }, DEBOUNCE_MS * attempts );
        } );
}

```
