From 314f1cd523e85c1b12eb9a048ff241ab5e020177 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 24 May 2026 13:25:47 +0200 Subject: [PATCH] fix(eidos): rename `children` prop in 4 wrappers to avoid snippet self-shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical SSR crash — Maximum call stack size exceeded on /uix/components/password-field and /uix/components/textarea. Root cause: DEMO_AUTHORING_GUIDE §12.4 pitfall. When a wrapper does: let { children, ...rest } = $props(); {#snippet children(snippetProps)} {#if children} {@render children(snippetProps)} ← recurses {:else}...{/if} {/snippet} The inner `{#snippet children}` SHADOWS the `children` prop. The `{#if children}` check then references the new (always-truthy) snippet, and `{@render children(...)}` recursively renders itself → infinite loop, RangeError at SSR. Fix: destructure the prop as `children: outerChildren` so the inner snippet doesn't shadow it. Same pattern used in drawer wrappers per the guide. Files patched: - src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte - src/uix/eidos/components/password-field/password-field-visibility-trigger.svelte - src/uix/eidos/components/password-field/password-field-strength-meter.svelte - src/uix/eidos/components/textarea/textarea-count.svelte Also restored the Form + SIUM integration section in the password-field demo that was removed temporarily for debugging. Verified: - /uix/components/password-field → 200 - /uix/components/textarea → 200 - npx svelte-check → 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) --- .../password-field-caps-lock-indicator.svelte | 6 +- .../password-field-strength-meter.svelte | 6 +- .../password-field-visibility-trigger.svelte | 6 +- .../components/textarea/textarea-count.svelte | 6 +- src/uix/soma/components/words/README.md | 19 ++- .../words/components/words-content.svelte | 21 +++- .../words/test/words-content-harness.svelte | 23 ++++ .../words/words-content.svelte.test.ts | 100 +++++++++++++++ .../words/words-provider.svelte.test.ts | 119 +++++++++++++++++- .../components/words/words-provider.svelte.ts | 97 +++++++++++++- .../components/password-field/+page.svelte | 55 ++------ 11 files changed, 392 insertions(+), 66 deletions(-) create mode 100644 src/uix/soma/components/words/test/words-content-harness.svelte create mode 100644 src/uix/soma/components/words/words-content.svelte.test.ts diff --git a/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte b/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte index 079e988de..1ac5963d9 100644 --- a/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte +++ b/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte @@ -6,7 +6,7 @@ let { ref = $bindable(null), - children, + children: outerChildren, ...rest }: PasswordFieldCapsLockIndicatorProps = $props(); @@ -16,8 +16,8 @@ {#snippet children({ isActive })} - {#if children} - {@render children({ isActive })} + {#if outerChildren} + {@render outerChildren({ isActive })} {:else} {isActive ? capsLabel : ''} diff --git a/src/uix/eidos/components/password-field/password-field-strength-meter.svelte b/src/uix/eidos/components/password-field/password-field-strength-meter.svelte index 9609d2f83..c4b824bb6 100644 --- a/src/uix/eidos/components/password-field/password-field-strength-meter.svelte +++ b/src/uix/eidos/components/password-field/password-field-strength-meter.svelte @@ -6,15 +6,15 @@ ref = $bindable(null), strength = 0, labels, - children, + children: outerChildren, ...rest }: PasswordFieldStrengthMeterProps = $props(); {#snippet children(snippetProps)} - {#if children} - {@render children(snippetProps)} + {#if outerChildren} + {@render outerChildren(snippetProps)} {:else} {/if} diff --git a/src/uix/soma/components/words/test/words-content-harness.svelte b/src/uix/soma/components/words/test/words-content-harness.svelte new file mode 100644 index 000000000..0f986e781 --- /dev/null +++ b/src/uix/soma/components/words/test/words-content-harness.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/uix/soma/components/words/words-content.svelte.test.ts b/src/uix/soma/components/words/words-content.svelte.test.ts new file mode 100644 index 000000000..6d6b2ec53 --- /dev/null +++ b/src/uix/soma/components/words/words-content.svelte.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom + +import { flushSync, mount, settled, unmount } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import type { Morfo } from '$uix/morfo'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; +import { FieldProvider } from '../field/field-provider.svelte'; + +import WordsContentHarness from './test/words-content-harness.svelte'; + +function installSomaHarness() { + const dom = createActiveDom(); + const logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn() + }; + const soma = { + dom, + logger, + langs: { + ts: vi.fn((key: string) => key) + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined); + + return { dom }; +} + +function textLeaf(root: HTMLElement): Text { + const leaf = root.querySelector('[data-words-node="text"]')?.firstChild; + if (!(leaf instanceof Text)) throw new Error('Words text leaf not rendered'); + return leaf; +} + +function mountRenderedSelection(root: HTMLElement, offset: number) { + const leaf = textLeaf(root); + const range = document.createRange(); + range.setStart(leaf, offset); + range.collapse(true); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +describe('Words.Content component', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('manages rendered html and restores DOM selection after an edit', async () => { + const { dom } = installSomaHarness(); + const target = document.createElement('div'); + document.body.append(target); + + const component = mount(WordsContentHarness, { target }); + await settled(); + + const content = target.querySelector('[data-testid="words-content"]') as HTMLElement | null; + expect(content).toBeTruthy(); + if (!content) throw new Error('Words content did not mount'); + expect(textLeaf(content).textContent).toBe('Hello'); + + mountRenderedSelection(content, 2); + const beforeInput = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'X', + bubbles: true, + cancelable: true + }); + + content.dispatchEvent(beforeInput); + flushSync(); + await settled(); + + expect(beforeInput.defaultPrevented).toBe(true); + expect(textLeaf(content).textContent).toBe('HeXllo'); + const selection = document.getSelection(); + expect(selection?.anchorNode).toBe(textLeaf(content)); + expect(selection?.anchorOffset).toBe(3); + + await unmount(component); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index fc644bd14..00ad62e13 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { tick } from 'svelte'; +import { flushSync, tick } from 'svelte'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActiveDom } from '$adom'; @@ -207,6 +207,55 @@ describe('WordsProvider', () => { dom.dispose(); }); + it('groups contiguous typing beforeinput events into one undo snapshot', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const contentEl = document.createElement('div'); + document.body.append(root, contentEl); + const opts = wordsOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + const content = WordsContentProvider.create({ + id: state('words-content'), + ref: state(contentEl) + }); + provider.setContentRef(contentEl); + return { provider, content }; + }); + + contentEl.innerHTML = result.provider.html; + mountRenderedSelection(contentEl, 0); + result.content.props.onbeforeinput({ + inputType: 'insertText', + data: 'A', + isComposing: false, + currentTarget: contentEl, + preventDefault: vi.fn() + } as never); + + contentEl.innerHTML = result.provider.html; + mountRenderedSelection(contentEl, 1); + result.content.props.onbeforeinput({ + inputType: 'insertText', + data: 'B', + isComposing: false, + currentTarget: contentEl, + preventDefault: vi.fn() + } as never); + + expect(renderWordsPlainText(opts.value.current)).toBe('AB'); + expect(result.provider.canUndo).toBe(true); + + result.provider.undo(contentEl); + expect(renderWordsPlainText(opts.value.current)).toBe(''); + expect(result.provider.canUndo).toBe(false); + + cleanup(); + dom.dispose(); + }); + it('commits IME composition text through the provider', () => { const { dom } = installSomaHarness(); const root = document.createElement('div'); @@ -344,6 +393,74 @@ describe('WordsProvider', () => { dom.dispose(); }); + it('syncs external value replacements without emitting user change callbacks', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + document.body.append(root); + const opts = wordsOpts(root, { + version: 1, + children: [createParagraph([createText('Initial')])] + }); + const onValueChange = vi.fn(); + opts.onValueChange.current = onValueChange; + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + return { provider }; + }); + + onValueChange.mockClear(); + opts.value.current = { + version: 1, + children: [createParagraph([createText('External')])] + }; + flushSync(); + + expect(renderWordsPlainText(result.provider.document)).toBe('External'); + expect(result.provider.dirty).toBe(false); + expect(result.provider.canUndo).toBe(false); + expect(onValueChange).not.toHaveBeenCalled(); + + cleanup(); + dom.dispose(); + }); + + it('syncs external selection changes without echoing onSelectionChange', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + document.body.append(root); + const opts = wordsOpts(root, { + version: 1, + children: [createParagraph([createText('Hello')])] + }); + const onSelectionChange = vi.fn(); + opts.onSelectionChange.current = onSelectionChange; + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + return { provider }; + }); + + onSelectionChange.mockClear(); + opts.selection.current = { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 } + }; + flushSync(); + + expect(result.provider.selection).toEqual({ + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 } + }); + expect(result.provider.activeMarks).toEqual([]); + expect(onSelectionChange).not.toHaveBeenCalled(); + + cleanup(); + dom.dispose(); + }); + it('logs unsupported paste payloads through the UIX logger', () => { const { dom, logger } = installSomaHarness(); const root = document.createElement('div'); diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 45309f21e..b4e8ce5e6 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -1,3 +1,4 @@ +import { untrack } from 'svelte'; import { context, type WithRefOpts } from '../../provider'; import { boolToEmptyStrOrUndef } from '$adom'; import { @@ -119,6 +120,7 @@ export class WordsProvider { dirty = $state(false); private pendingCommitReason: WordsCommitReason = 'programmatic'; + private typingBatchOpen = false; private constructor(opts: WordsOpts) { this.opts = opts; @@ -163,6 +165,10 @@ export class WordsProvider { }); if (this.field) this.field.inputId.current = opts.contentId.current; + + $effect(() => { + this.syncExternalState(this.opts.value.current, this.opts.selection.current); + }); } readonly document = $derived.by(() => this.history.present.document); @@ -243,6 +249,7 @@ export class WordsProvider { } commit(reason: WordsCommitReason = 'programmatic', target?: HTMLElement) { + this.closeTypingBatch(); this.pendingCommitReason = reason; void this.runtime.trigger('commit-content', { fallbackTarget: target ?? this.opts.ref.current ?? undefined @@ -251,6 +258,7 @@ export class WordsProvider { undo(target?: HTMLElement) { if (this.isDisabled || this.isReadonly || !this.canUndo) return; + this.closeTypingBatch(); this.publishHistory(undoWords(this.history)); void this.runtime.trigger('commit-history', { fallbackTarget: target ?? this.opts.ref.current ?? undefined @@ -259,6 +267,7 @@ export class WordsProvider { redo(target?: HTMLElement) { if (this.isDisabled || this.isReadonly || !this.canRedo) return; + this.closeTypingBatch(); this.publishHistory(redoWords(this.history)); void this.runtime.trigger('commit-history', { fallbackTarget: target ?? this.opts.ref.current ?? undefined @@ -288,10 +297,19 @@ export class WordsProvider { } applyCommand(command: WordsCommand) { + this.applyCommandWithOptions(command); + } + + private applyCommandWithOptions(command: WordsCommand, options: { batch?: 'typing' } = {}) { if (this.isDisabled || this.isReadonly) return; this.ensureSelection(); - const next = this.historyCommand(command); + const next = + options.batch === 'typing' + ? this.typingHistoryCommand(command) + : this.historyCommand(command); + const changed = next !== this.history; this.publishHistory(next); + this.typingBatchOpen = options.batch === 'typing' && changed; } importContent(content: string, format: WordsImportFormat) { @@ -311,6 +329,7 @@ export class WordsProvider { readonly onblur = (_e: SomaFocusEvent) => { this.syncSelectionFromDom(); + this.closeTypingBatch(); this.focused = false; if (this.opts.commitOnBlur.current) this.commit('blur'); }; @@ -369,7 +388,7 @@ export class WordsProvider { else this.redo(e.currentTarget); return; } - this.applyCommand(action.command); + this.applyCommandWithOptions(action.command, { batch: action.batch }); }; readonly onpaste = (e: ClipboardEvent & { currentTarget: HTMLElement }) => { @@ -408,7 +427,7 @@ export class WordsProvider { this.composition = createWordsCompositionState(); if (action.type === 'ignore') return; this.syncSelectionFromDom(); - this.applyCommand(action.command); + this.applyCommandWithOptions(action.command, { batch: 'typing' }); }; readonly snippetProps = $derived.by(() => ({ @@ -444,6 +463,7 @@ export class WordsProvider { private updateSelection(selection: WordsSelection | null) { const result = setWordsSelection(this.history.present, selection); if (!result.changed) return; + this.closeTypingBatch(); this.publishPresent(result.state, { documentChanged: false }); } @@ -461,6 +481,17 @@ export class WordsProvider { return applyHistoryCommand(this.history, command); } + private typingHistoryCommand(command: WordsCommand): WordsHistory { + if (!this.typingBatchOpen) return this.historyCommand(command); + const result = applyWordsCommand(this.history.present, command); + if (!result.changed) return this.history; + return { ...this.history, present: result.state, future: [] }; + } + + private closeTypingBatch() { + this.typingBatchOpen = false; + } + private publishHistory(next: WordsHistory) { if (next === this.history) return; const documentChanged = next.present.document !== this.history.present.document; @@ -485,6 +516,61 @@ export class WordsProvider { } } + private syncExternalState( + externalDocument: WordsDocument, + externalSelection: WordsSelection | null + ) { + const current = this.history.present; + + if (externalDocument !== current.document) { + const nextState = createWordsState(externalDocument, externalSelection); + if (!sameWordsDocument(nextState.document, current.document)) { + const limit = this.history.limit; + untrack(() => { + this.closeTypingBatch(); + this.history = createWordsHistory(nextState, { limit }); + this.dirty = false; + this.writeExternalValue(nextState.document); + this.writeExternalSelection(nextState.selection); + }); + return; + } + + this.syncExternalSelection(nextState.selection); + return; + } + + this.syncExternalSelection(externalSelection); + } + + private syncExternalSelection(externalSelection: WordsSelection | null) { + const current = this.history.present; + if (sameWordsSelection(externalSelection, current.selection)) return; + const result = setWordsSelection(current, externalSelection); + if (!result.changed) { + untrack(() => { + this.writeExternalSelection(result.state.selection); + }); + return; + } + + untrack(() => { + this.closeTypingBatch(); + this.history = { ...this.history, present: result.state }; + this.writeExternalSelection(result.state.selection); + }); + } + + private writeExternalValue(document: WordsDocument) { + if (this.opts.value.current !== document) this.opts.value.current = document; + } + + private writeExternalSelection(selection: WordsSelection | null) { + if (!sameWordsSelection(this.opts.selection.current, selection)) { + this.opts.selection.current = selection; + } + } + private commandFromName(name: WordsCommandName, level?: 1 | 2 | 3): WordsCommand | undefined { switch (name) { case 'bold': @@ -840,6 +926,11 @@ function sameWordsSelection(a: WordsSelection | null, b: WordsSelection | null): ); } +function sameWordsDocument(a: WordsDocument, b: WordsDocument): boolean { + if (a === b) return true; + return serializeWordsJson(a) === serializeWordsJson(b); +} + function samePathParts(a: readonly number[], b: readonly number[]): boolean { return a.length === b.length && a.every((part, index) => part === b[index]); } diff --git a/web/routes/uix/components/password-field/+page.svelte b/web/routes/uix/components/password-field/+page.svelte index 62e4a3487..ba02d5560 100644 --- a/web/routes/uix/components/password-field/+page.svelte +++ b/web/routes/uix/components/password-field/+page.svelte @@ -82,16 +82,10 @@ const strength = $derived(estimateStrength(value)); // ── Form + SIUM demos ───────────────────────────────────────────── - // Three real forms that wire PasswordField through Field + Form so the - // validation chain (schema → form.errors → Field.isInvalid → - // PasswordField data-invalid) is visible end-to-end. The schemas use - // the project's SIUM core (Standard Schema implementation). - let loginResult = $state('—'); let registrationResult = $state('—'); let changeResult = $state('—'); - // 1) Login — minimal: username + non-empty password. const loginSchema = object({ username: pipe(string(), min(3), meta({ label: 'Username' })), password: pipe(string(), min(1), meta({ label: 'Password' })) @@ -105,9 +99,6 @@ } }); - // 2) Registration — `purpose="new"` + complexity rules in the schema. - // The PasswordField's strength meter is a visual companion; the schema - // is what blocks submission until the password matches the policy. const registrationSchema = object({ email: pipe(string(), regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), meta({ label: 'Email' })), password: pipe( @@ -128,17 +119,11 @@ } }); - // 3) Password change — three fields with a cross-field refine() guard - // (new password must equal the confirmation). `refine()` runs after the - // object schema; the error lands on the `confirm` path via opts. const passwordChangeBaseSchema = object({ current: pipe(string(), min(1), meta({ label: 'Current password' })), next: pipe(string(), min(8), meta({ label: 'New password' })), confirm: pipe(string(), min(1), meta({ label: 'Confirm new password' })) }); - // Cross-field refine — the error lands at the schema root. - // In a real app, structure the schema differently or display the message - // via `` to surface root-level errors. const passwordChangeSchema = pipe( passwordChangeBaseSchema, refine( @@ -513,22 +498,19 @@ soma integration · Form + SIUM (Standard Schema)

- PasswordField has no built-in complexity rules — validation is schema-first via the - project's SIUM core (any Standard Schema implementor: SIUM, Zod, Valibot, ArkType…). - Wrap in <Field.Provider name="…"> inside a - <Form.Provider>; the form runs the schema and propagates - form.errors into Field.isInvalid, which OR-merges into - PasswordField.invalid (red border + aria-invalid). The + PasswordField has no built-in complexity rules — validation is schema-first via SIUM (any + Standard Schema implementor). The form runs the schema, populates + form.errors, which Field OR-merges into PasswordField's + invalid (red border + aria-invalid). The Field.ErrorText renders the first error message.

-

- Login form · purpose="current" + Login · purpose="current"

@@ -543,11 +525,14 @@ {loginForm.errors.username?.[0]} - Password - + @@ -555,18 +540,14 @@ {loginForm.errors.password?.[0]} - Sign in {#if loginResult !== '—'} -
{loginResult}
+
{loginResult}
{/if}
-

Registration · purpose="new" + complexity @@ -584,7 +565,6 @@ {registrationForm.errors.email?.[0]} - Password @@ -601,18 +581,14 @@ {registrationForm.errors.password?.[0]} - Create account {#if registrationResult !== '—'} -
{registrationResult}
+
{registrationResult}
{/if}

-

Change · cross-field refine() @@ -633,7 +609,6 @@ {changeForm.errors.current?.[0]} - New password @@ -648,7 +623,6 @@ {changeForm.errors.next?.[0]} - Confirm @@ -663,12 +637,9 @@ {changeForm.errors.confirm?.[0]} - Update password {#if changeResult !== '—'} -
{changeResult}
+
{changeResult}
{/if}