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:
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user