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_id,
field_name,
current_value = $bindable(),
current_value,
field_type = 'text',
allow_null = false,
select_options = {},
@@ -70,15 +70,31 @@
let error_message = $state('');
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(() => {
if (!is_editing) {
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() {
if (log_lvl) console.log(`AE Field Editor V3: Patching ${object_type}.${field_name}...`);
@@ -98,7 +114,7 @@
if (result) {
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);
// Close edit mode after a brief success indicator
@@ -120,6 +136,7 @@
}
function cancel_edit() {
has_optimistic = false;
draft_value = current_value;
is_editing = false;
patch_status = 'idle';
@@ -128,7 +145,11 @@
function toggle_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>
@@ -143,31 +164,33 @@
{#if children}
{@render children()}
{:else if field_type === 'checkbox'}
<span class="badge {current_value ? 'variant-filled-success' : 'variant-soft-surface'}">
{current_value ? 'Enabled' : 'Disabled'}
<span class="badge {display_value ? 'variant-filled-success' : 'variant-soft-surface'}">
{display_value ? 'Enabled' : 'Disabled'}
</span>
{:else if field_type === 'tiptap'}
<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>
{:else}
<span class:opacity-50={!current_value}>
{current_value || 'Not set'}
<span class:opacity-50={!display_value}>
{display_value || 'Not set'}
</span>
{/if}
</div>
<!-- Edit Trigger (Visible on Hover or Edit Mode) -->
{#if $ae_loc.edit_mode}
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-warning opacity-0 group-hover:opacity-100 transition-opacity"
onclick={toggle_edit}
title="Edit {field_name}"
>
<SquarePen size="14" />
</button>
{/if}
<!-- WHY: Always render to avoid layout shift when edit_mode toggles.
Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-warning opacity-0 group-hover:opacity-100 transition-opacity"
class:invisible={!$ae_loc.edit_mode}
class:pointer-events-none={!$ae_loc.edit_mode}
onclick={toggle_edit}
title="Edit {field_name}"
>
<SquarePen size="14" />
</button>
</div>
<!-- EDIT MODE -->
@@ -263,7 +286,7 @@
type="button"
class="btn btn-sm variant-filled-primary"
onclick={handle_patch}
disabled={patch_status === 'processing' || draft_value === current_value}
disabled={patch_status === 'processing' || draft_value === display_value}
>
{#if patch_status === 'processing'}
<LoaderCircle size="14" class="animate-spin mr-1" />