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();
<Inner>
{#snippet children(snippetProps)}
{#if children}
{@render children(snippetProps)} ← recurses
{:else}...{/if}
{/snippet}
</Inner>
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) <noreply@anthropic.com>
active-uix
parent
8d277fe929
commit
314f1cd523
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import * as Words from '../index';
|
||||
import { createParagraph, createText, type WordsDocument, type WordsSelection } from '../engine';
|
||||
|
||||
let {
|
||||
value = $bindable(sampleDocument()),
|
||||
selection = $bindable(null)
|
||||
}: {
|
||||
value?: WordsDocument;
|
||||
selection?: WordsSelection | null;
|
||||
} = $props();
|
||||
|
||||
function sampleDocument(): WordsDocument {
|
||||
return {
|
||||
version: 1,
|
||||
children: [createParagraph([createText('Hello')])]
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<Words.Provider bind:value bind:selection>
|
||||
<Words.Content data-testid="words-content" />
|
||||
</Words.Provider>
|
||||
@ -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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue