From 71e675489c9d4691b3e84e7c97d239f3a141ef6e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 13:46:52 -0700 Subject: [PATCH] fix(builder): Footer commits in-progress edits on deselect, not just blur (D7) Footer only committed edited text via onBlur, and its effect rewrote innerText from the (stale) text prop whenever selected became false -- if selection cleared without a real DOM blur, the in-progress edit was silently lost. Adopt Heading.tsx's exact mechanism: an editedTextRef updated on onInput, committed to the prop via an effect keyed on the selected->false transition (in addition to the existing onBlur commit). Preserves the 500ms setProp debounce Footer already had for undo grouping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../basic/Footer.editguard.test.tsx | 105 ++++++++++++++++++ craft/src/components/basic/Footer.tsx | 27 ++++- 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 craft/src/components/basic/Footer.editguard.test.tsx diff --git a/craft/src/components/basic/Footer.editguard.test.tsx b/craft/src/components/basic/Footer.editguard.test.tsx new file mode 100644 index 0000000..95a03ff --- /dev/null +++ b/craft/src/components/basic/Footer.editguard.test.tsx @@ -0,0 +1,105 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* Footer only needs useNode from @craftjs/core. Mock it following the + DOM-harness pattern in src/state/PageContext.slug.test.tsx (no + @testing-library/react in this repo) so we can drive `selected` across + re-renders and observe setProp calls without a real tree. */ +let mockSelected = false; +let lastCommittedProps: { text: string } = { text: '' }; +const setPropSpy = vi.fn((updater: (p: any) => void) => { + updater(lastCommittedProps); +}); + +vi.mock('@craftjs/core', () => ({ + useNode: (collect?: (node: any) => any) => { + const node = { events: { selected: mockSelected } }; + return { + connectors: { connect: (el: any) => el, drag: (el: any) => el }, + actions: { setProp: setPropSpy }, + ...(collect ? collect(node) : {}), + }; + }, +})); + +import { Footer } from './Footer'; + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +function rerender(ui: React.ReactElement) { + act(() => { + root.render(ui); + }); +} + +beforeEach(() => { + mockSelected = false; + lastCommittedProps = { text: 'Original' }; + setPropSpy.mockClear(); +}); + +describe('Footer edit-guard (mirrors Heading.tsx mechanism)', () => { + test('deselecting without a real blur still commits the in-progress edit', () => { + mockSelected = true; + render(