WIDGET POSITION PERSISTENCE FIX
Ecosistema completo para organización y gestión de contenido con interfaces espaciales
Widget Position Persistence Fix
Problem Summary
Widget positions were not persisting across browsers after drag-and-drop operations. When a widget was moved in one browser, it would revert to its original position when viewed in another browser or after page refresh.
Root Causes Identified
1. Missing Backend Sync
- Issue: Widgets were saved to flash-persistence (local IndexedDB) but not automatically synced to the backend
- Impact: Other browsers couldn't see position changes because they loaded from backend, which didn't have the updates
- Location:
libs/libs-ui/editor-ui/src/lib/services/editor.service.ts
2. Breakpoint Double-Encoding
- Issue: Breakpoint values were being double/triple JSON-encoded (e.g.,
"\"\\\"base\\\"\""instead of"base") - Impact: Position updates failed because the breakpoint key lookup couldn't find the correct key in the responsive position structure
- Location:
libs/libs-widgets/widgets/src/lib/directives/responsive-draggable.directive.ts
3. Position Comparison Logic
- Issue:
handleDragEndwas returning widgets without recalculating position when movement occurred - Impact: Updated positions from
updateWidgetPositionModelwere being lost - Location:
libs/libs-widgets/widgets/src/lib/widgets/widget-host/services/widget-drag-handler.service.ts
Solutions Implemented
1. Automatic Backend Sync (editor.service.ts)
Added debounced backend sync that automatically saves widgets to the backend after 1 second of inactivity:
// Setup automatic backend sync
private setupBackendSync(): void {
this.widgetChangeSubject
.pipe(
debounceTime(1000), // Wait 1 second after last change
distinctUntilChanged((a: Widget[], b: Widget[]) => {
// Only sync if widgets actually changed
const createSignature = (widgets: Widget[]) => {
return widgets
.map(w => `${w.id}:${JSON.stringify(w.position)}`)
.sort()
.join('|');
};
return createSignature(a) === createSignature(b);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (widgets) => {
// Trigger backend save via action handler
this.actionHandler.execute('editor:save-current-page', {
page: currentPage,
widgets: widgets
}).subscribe({...});
}
});
}
Key Features:
- Debounced to reduce backend calls (1 second delay)
- Only syncs when widgets actually change (by ID and position)
- Updates
lastSavedtimestamp on success - Handles errors gracefully
2. Breakpoint Cleaning (responsive-draggable.directive.ts)
Added breakpoint normalization to handle double-encoded strings:
private updateWidgetPositionModel(deltaX: number, deltaY: number): void {
let breakpoint: BreakpointKey = this.activeBreakpoint();
// Clean breakpoint if it's double-encoded
if (typeof breakpoint === 'string') {
let cleanedBreakpointStr: string = breakpoint;
// Remove JSON encoding artifacts (handle double/triple encoding)
// Example: "\"\\\"BASE\\\"\"" -> "BASE" -> "base"
while (cleanedBreakpointStr.startsWith('"') && cleanedBreakpointStr.endsWith('"')) {
try {
const parsed = JSON.parse(cleanedBreakpointStr);
if (typeof parsed === 'string') {
cleanedBreakpointStr = parsed;
} else {
break;
}
} catch {
cleanedBreakpointStr = cleanedBreakpointStr.slice(1, -1);
}
}
// Normalize to lowercase and validate
cleanedBreakpointStr = cleanedBreakpointStr.toLowerCase().trim();
const validBreakpoints: BreakpointKey[] = ['base', 'sm', 'md', 'lg', 'xl', '2xl'];
breakpoint = validBreakpoints.includes(cleanedBreakpointStr as BreakpointKey)
? (cleanedBreakpointStr as BreakpointKey)
: ('base' as BreakpointKey);
}
// Use cleaned breakpoint for position updates
responsivePosition[breakpoint] = newPosition;
}
Key Features:
- Handles multiple levels of JSON encoding
- Normalizes to lowercase
- Validates against known breakpoint keys
- Defaults to 'base' if invalid
3. Position Recalculation (widget-drag-handler.service.ts)
Fixed logic to always recalculate position when movement occurs:
handleDragEnd(widget: Widget, event: ResponsiveDragEndEvent, activeBreakpoint: BreakpointKey, zoom: number): Widget {
// Only skip recalculation if there's NO movement
if (breakpointPos &&
typeof breakpointPos.x === 'number' &&
typeof breakpointPos.y === 'number' &&
event.deltaX === 0 &&
event.deltaY === 0) {
// No movement - position is already correct
return widget;
}
// There was movement - MUST recalculate to ensure position is updated
const currentPosition = this.responsiveWidgetService.getWidgetPosition(widget, activeBreakpoint);
const newPosition = currentPosition && ...
return this.responsiveWidgetService.setWidgetPropertyForBreakpoint(...);
}
Key Features:
- Always recalculates when
deltaX !== 0ordeltaY !== 0 - Only skips recalculation when there's truly no movement
- Ensures position is always updated correctly
4. Signal Update Timing (responsive-draggable.directive.ts)
Added queueMicrotask to ensure signal updates propagate:
this.updateWidgetPositionModel(deltaX, deltaY);
queueMicrotask(() => {
const updatedWidget = this.widget(); // Get widget AFTER signal update
this.dragEnd.emit({
widget: updatedWidget, // This now has the updated position
breakpoint: breakpoint,
});
});
Key Features:
- Ensures signal update has propagated before reading
- Verifies position was actually updated
- Emits widget with correct position
Data Flow
Before Fix:
1. User drags widget
2. updateWidgetPositionModel() updates widget signal
3. handleDragEnd() returns widget WITHOUT recalculating (bug!)
4. Widget position NOT updated in backend
5. Other browsers load OLD position from backend
After Fix:
1. User drags widget
2. updateWidgetPositionModel() updates widget signal (with cleaned breakpoint)
3. queueMicrotask ensures signal propagates
4. handleDragEnd() ALWAYS recalculates when movement detected
5. Widget position updated in flash-persistence (instant)
6. After 1 second, auto-sync saves to backend
7. Other browsers load NEW position from backend ✅
Testing Checklist
- Widget position persists in same browser after refresh
- Widget position persists across different browsers
- Position updates sync to backend automatically
- Breakpoint badge displays correctly (not double-encoded)
- No infinite loops when updating positions
- Position changes detected correctly in comparison logic
Related Files
libs/libs-ui/editor-ui/src/lib/services/editor.service.ts- Backend synclibs/libs-widgets/widgets/src/lib/directives/responsive-draggable.directive.ts- Breakpoint cleaning, position updateslibs/libs-widgets/widgets/src/lib/widgets/widget-host/services/widget-drag-handler.service.ts- Position recalculationlibs/libs-widgets/widgets/src/lib/widgets/widget-host/widget-host.component.ts- Drag event handlinglibs/libs-widgets/widgets/src/lib/widgets/editor-canvas/services/canvas-operations.service.ts- Position comparison
Future Improvements
- Reduce debounce time for faster sync (currently 1 second)
- Add conflict resolution for concurrent edits
- Optimize position comparison to avoid deep JSON.stringify
- Add position update batching for multiple widgets