fix(field-editor-v3): layout shift, bindable crash, and optimistic display

- Fix layout shift on edit_mode toggle: always render the edit button
  (using invisible/pointer-events-none) so the flex container doesn't
  reflow when edit_mode is toggled on/off.

- Fix 'store.set is not a function' crash: remove $bindable() from
  current_value. The component is SWR-first; after a successful PATCH
  liveQuery updates the prop from Dexie. Trying to write back to a
  readonly liveQuery-derived prop caused the crash.

- Fix stale display after save: add has_optimistic flag + display_value
  derived. After a successful PATCH, display_value shows draft_value
  immediately without waiting for liveQuery. Cleared automatically when
  current_value catches up, or on cancel/re-open of edit mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-04 20:05:43 -05:00
parent b2fa6228a6
commit c3ec0f88ee

View File

@@ -47,7 +47,7 @@
object_type, object_type,
object_id, object_id,
field_name, field_name,
current_value = $bindable(), current_value,
field_type = 'text', field_type = 'text',
allow_null = false, allow_null = false,
select_options = {}, select_options = {},
@@ -70,15 +70,31 @@
let error_message = $state(''); let error_message = $state('');
let draft_value = $state(current_value); let draft_value = $state(current_value);
// Sync draft with current_value when not editing // WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We show
// draft_value as the display until current_value catches up from liveQuery.
let has_optimistic = $state(false);
let display_value = $derived(has_optimistic ? draft_value : current_value);
// Sync draft with display_value when not editing.
// Suppress reset if optimistic is active — we already have the right value.
$effect(() => { $effect(() => {
if (!is_editing) { if (!is_editing) {
untrack(() => { untrack(() => {
draft_value = current_value; if (!has_optimistic) {
draft_value = current_value;
}
}); });
} }
}); });
// Clear optimistic once liveQuery catches up (current_value matches what we saved)
$effect(() => {
if (has_optimistic && current_value === draft_value) {
has_optimistic = false;
}
});
async function handle_patch() { async function handle_patch() {
if (log_lvl) console.log(`AE Field Editor V3: Patching ${object_type}.${field_name}...`); if (log_lvl) console.log(`AE Field Editor V3: Patching ${object_type}.${field_name}...`);
@@ -98,7 +114,7 @@
if (result) { if (result) {
patch_status = 'success'; patch_status = 'success';
current_value = draft_value; has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
if (on_success) on_success(result); if (on_success) on_success(result);
// Close edit mode after a brief success indicator // Close edit mode after a brief success indicator
@@ -120,6 +136,7 @@
} }
function cancel_edit() { function cancel_edit() {
has_optimistic = false;
draft_value = current_value; draft_value = current_value;
is_editing = false; is_editing = false;
patch_status = 'idle'; patch_status = 'idle';
@@ -128,7 +145,11 @@
function toggle_edit() { function toggle_edit() {
if (is_editing) cancel_edit(); if (is_editing) cancel_edit();
else is_editing = true; else {
has_optimistic = false; // clear optimistic so draft syncs from current prop
draft_value = current_value;
is_editing = true;
}
} }
</script> </script>
@@ -143,31 +164,33 @@
{#if children} {#if children}
{@render children()} {@render children()}
{:else if field_type === 'checkbox'} {:else if field_type === 'checkbox'}
<span class="badge {current_value ? 'variant-filled-success' : 'variant-soft-surface'}"> <span class="badge {display_value ? 'variant-filled-success' : 'variant-soft-surface'}">
{current_value ? 'Enabled' : 'Disabled'} {display_value ? 'Enabled' : 'Disabled'}
</span> </span>
{:else if field_type === 'tiptap'} {:else if field_type === 'tiptap'}
<div class="prose dark:prose-invert max-w-none"> <div class="prose dark:prose-invert max-w-none">
{@html current_value || '<span class="opacity-50 italic">Empty</span>'} {@html display_value || '<span class="opacity-50 italic">Empty</span>'}
</div> </div>
{:else} {:else}
<span class:opacity-50={!current_value}> <span class:opacity-50={!display_value}>
{current_value || 'Not set'} {display_value || 'Not set'}
</span> </span>
{/if} {/if}
</div> </div>
<!-- Edit Trigger (Visible on Hover or Edit Mode) --> <!-- Edit Trigger (Visible on Hover or Edit Mode) -->
{#if $ae_loc.edit_mode} <!-- WHY: Always render to avoid layout shift when edit_mode toggles.
<button Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
type="button" <button
class="btn-icon btn-icon-sm variant-soft-warning opacity-0 group-hover:opacity-100 transition-opacity" type="button"
onclick={toggle_edit} class="btn-icon btn-icon-sm variant-soft-warning opacity-0 group-hover:opacity-100 transition-opacity"
title="Edit {field_name}" class:invisible={!$ae_loc.edit_mode}
> class:pointer-events-none={!$ae_loc.edit_mode}
<SquarePen size="14" /> onclick={toggle_edit}
</button> title="Edit {field_name}"
{/if} >
<SquarePen size="14" />
</button>
</div> </div>
<!-- EDIT MODE --> <!-- EDIT MODE -->
@@ -263,7 +286,7 @@
type="button" type="button"
class="btn btn-sm variant-filled-primary" class="btn btn-sm variant-filled-primary"
onclick={handle_patch} onclick={handle_patch}
disabled={patch_status === 'processing' || draft_value === current_value} disabled={patch_status === 'processing' || draft_value === display_value}
> >
{#if patch_status === 'processing'} {#if patch_status === 'processing'}
<LoaderCircle size="14" class="animate-spin mr-1" /> <LoaderCircle size="14" class="animate-spin mr-1" />