Skip to content

Commit e1ce751

Browse files
authored
1687 Fix: unsubmitted changes get stomped on (#1768)
* Prevent stomping unsaved changes in input fields * Prevent putting unsaved changes in initialEntry * Remove redundant entry requerying and rely on events as source of truth * Prevent duplicate calls to parrentSetter * Fix subscribers not updated when dirty changed * More elegant names * Add comment * Remove unused import
1 parent cea3ee7 commit e1ce751

19 files changed

Lines changed: 814 additions & 277 deletions

frontend/pnpm-lock.yaml

Lines changed: 470 additions & 242 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/pnpm-workspace.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ catalog:
1313
tslib: ^2.6.2
1414
tailwindcss: ^3.4.17
1515
eslint: ^9.29.0
16-
vitest: ^3.2.2
17-
"@vitest/ui": ^3.2.2
18-
"@vitest/browser": ^3.2.2
16+
vitest: ^3.2.3
17+
"@vitest/ui": ^3.2.3
18+
"@vitest/browser": ^3.2.3
1919
svelte-exmarkdown: ^4.0.3
2020
"@typescript-eslint/eslint-plugin": ^8.24.0
2121
"@typescript-eslint/parser": ^8.24.0

frontend/viewer/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"test:ui": "vitest --ui",
3030
"test:watch": "vitest",
3131
"test:storybook": "vitest --project=storybook",
32+
"test:unit": "vitest --project=unit",
3233
"check": "svelte-check",
3334
"lint": "eslint .",
3435
"lint:report": "eslint . --output-file eslint_report.json --format json",
@@ -46,7 +47,6 @@
4647
"@lingui/format-json": "^5.2.0",
4748
"@lingui/vite-plugin": "^5.2.0",
4849
"@mdi/js": "^7.4.47",
49-
"playwright": "catalog:",
5050
"@playwright/test": "catalog:",
5151
"@storybook/addon-a11y": "^9.0.8",
5252
"@storybook/addon-docs": "^9.0.8",
@@ -69,9 +69,9 @@
6969
"eslint": "catalog:",
7070
"eslint-plugin-storybook": "^9.0.8",
7171
"eslint-plugin-svelte": "catalog:",
72-
"happy-dom": "^17.1.0",
7372
"mode-watcher": "^1.0.7",
7473
"paneforge": "1.0.0-next.4",
74+
"playwright": "catalog:",
7575
"storybook": "^9.0.8",
7676
"svelte": "catalog:",
7777
"svelte-check": "catalog:",
@@ -96,6 +96,7 @@
9696
"@microsoft/signalr": "^8.0.0",
9797
"autoprefixer": "^10.4.19",
9898
"fast-json-patch": "^3.1.1",
99+
"jsdom": "^26.1.0",
99100
"just-throttle": "^4.2.0",
100101
"postcss": "catalog:",
101102
"prosemirror-commands": "^1.7.0",

frontend/viewer/src/lib/DictionaryEntry.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import type { IEntry, ISense } from '$lib/dotnet-types';
3-
import {useWritingSystemService} from './writing-system-service.svelte';
3+
import {asString, useWritingSystemService} from './writing-system-service.svelte';
44
import { usePartsOfSpeech } from './parts-of-speech.svelte';
55
import type {HTMLAttributes} from 'svelte/elements';
66
import {Icon} from '$lib/components/ui/icon';
@@ -53,19 +53,19 @@
5353
wsId: ws.wsId,
5454
wsAbbr: ws.abbreviation,
5555
gloss: sense.gloss[ws.wsId],
56-
definition: wsService.asString(sense.definition[ws.wsId]),
56+
definition: asString(sense.definition[ws.wsId]),
5757
color: wsService.wsColor(ws.wsId, 'analysis'),
5858
}))
5959
.filter(({gloss, definition}) => gloss || definition),
6060
exampleSentences: sense.exampleSentences.map(example => ({
6161
id: example.id,
6262
sentences: [
6363
...wsService.vernacular.map(ws => ({
64-
text: wsService.asString(example.sentence[ws.wsId]),
64+
text: asString(example.sentence[ws.wsId]),
6565
color: wsService.wsColor(ws.wsId, 'vernacular'),
6666
})),
6767
...wsService.analysis.map(ws => ({
68-
text: wsService.asString(example.translation[ws.wsId]),
68+
text: asString(example.translation[ws.wsId]),
6969
color: wsService.wsColor(ws.wsId, 'analysis'),
7070
})),
7171
].filter(({text}) => !!text),

frontend/viewer/src/lib/components/field-editors/multi-ws-input.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script lang="ts">
22
import {type IMultiString, type IWritingSystem} from '$lib/dotnet-types';
33
import type {ReadonlyDeep} from 'type-fest';
4-
import {Input} from '../ui/input';
54
import {tryUseFieldBody} from '../editor/field/field-root.svelte';
65
import {Label} from '../ui/label';
6+
import StompSafeInput from '../stomp/stomp-safe-input.svelte';
77
88
const fieldBodyProps = tryUseFieldBody();
99
const labeledBy = fieldBodyProps?.labelId;
@@ -36,7 +36,7 @@
3636
<div class="grid gap-y-2 @lg/editor:grid-cols-subgrid col-span-full items-baseline"
3737
title={`${ws.name} (${ws.wsId})`}>
3838
<Label id={labelId} for={inputId}>{ws.abbreviation}</Label>
39-
<Input bind:value={value[ws.wsId]}
39+
<StompSafeInput bind:value={value[ws.wsId]}
4040
id={inputId}
4141
aria-labelledby="{labeledBy ?? ''} {labelId}"
4242
{readonly}

frontend/viewer/src/lib/components/field-editors/rich-multi-ws-input.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script lang="ts">
22
import {type IRichMultiString, type IWritingSystem} from '$lib/dotnet-types';
33
import type {ReadonlyDeep} from 'type-fest';
4-
import LcmRichTextEditor from '../lcm-rich-text-editor/lcm-rich-text-editor.svelte';
54
import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString';
65
import {tryUseFieldBody} from '../editor/field/field-root.svelte';
76
import {Label} from '../ui/label';
7+
import StompSafeLcmRichTextEditor from '../stomp/stomp-safe-lcm-rich-text-editor.svelte';
88
99
const fieldBodyProps = tryUseFieldBody();
1010
const labelledBy = fieldBodyProps?.labelId;
@@ -43,7 +43,7 @@
4343
<div class="grid gap-y-2 @lg/editor:grid-cols-subgrid col-span-full items-baseline"
4444
title={`${ws.name} (${ws.wsId})`}>
4545
<Label id={labelId} for={inputId}>{ws.abbreviation}</Label>
46-
<LcmRichTextEditor
46+
<StompSafeLcmRichTextEditor
4747
bind:value={value[ws.wsId]}
4848
normalWs={ws.wsId}
4949
id={inputId}

frontend/viewer/src/lib/components/field-editors/rich-ws-input.svelte

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script lang="ts">
22
import type { IWritingSystem } from '$lib/dotnet-types';
33
import type { ReadonlyDeep } from 'type-fest';
4-
import LcmRichTextEditor from '../lcm-rich-text-editor/lcm-rich-text-editor.svelte';
54
import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString';
65
import {tryUseFieldBody} from '../editor/field/field-root.svelte';
6+
import StompSafeLcmRichTextEditor from '../stomp/stomp-safe-lcm-rich-text-editor.svelte';
77
88
const fieldBodyProps = tryUseFieldBody();
99
const labelledBy = fieldBodyProps?.labelId;
@@ -21,15 +21,14 @@
2121
2222
const { readonly = false, writingSystem: ws, onchange, autofocus } = $derived(constProps);
2323
24-
2524
function onRichTextChange() {
2625
value?.spans.forEach((span) => span.ws ??= ws.wsId);
2726
onchange?.(value);
2827
}
2928
</script>
3029

31-
<LcmRichTextEditor
32-
bind:value={value}
30+
<StompSafeLcmRichTextEditor
31+
bind:value
3332
normalWs={ws.wsId}
3433
onchange={onRichTextChange}
3534
{readonly}

frontend/viewer/src/lib/components/field-editors/ws-input.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts">
22
import type { IWritingSystem } from '$lib/dotnet-types';
33
import type { ReadonlyDeep } from 'type-fest';
4-
import { Input } from '../ui/input';
54
import {tryUseFieldBody} from '../editor/field/field-root.svelte';
5+
import StompSafeInput from '../stomp/stomp-safe-input.svelte';
66
77
const fieldBodyProps = tryUseFieldBody();
88
const labelledBy = fieldBodyProps?.labelId;
@@ -22,7 +22,7 @@
2222
const { readonly = false, writingSystem: ws, onchange } = $derived(constProps);
2323
</script>
2424

25-
<Input
25+
<StompSafeInput
2626
{readonly}
2727
bind:value
2828
title={`${ws.name} (${ws.wsId})`}

frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,8 @@
9292
if (!newState.doc.eq(editor.state.doc)) {
9393
//todo, eventually we might want to let the user edit span props, not sure if node attributes or marks are the correct way to handle that
9494
//I suspect marks is the right way though.
95-
if (!value) value = {spans: []};
96-
value.spans = newState.doc.children.map((child) => richSpanFromNode(child))
97-
.filter(s => s.text);
95+
value = { spans: newState.doc.children.map((child) => richSpanFromNode(child))
96+
.filter(s => s.text) };
9897
dirty = true;
9998
}
10099
editor.updateState(newState);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
2+
3+
import {StompGuard} from './stomp-guard.svelte';
4+
import {tick} from 'svelte';
5+
import {watch} from 'runed';
6+
7+
describe('StompGuard', () => {
8+
9+
let stompGuard: StompGuard<number>;
10+
let parentValue = $state(42);
11+
let cleanup: () => void;
12+
13+
beforeEach(() => {
14+
cleanup = $effect.root(() => {
15+
stompGuard = new StompGuard(() => parentValue, (value) => parentValue = value);
16+
});
17+
});
18+
19+
afterEach(() => {
20+
cleanup();
21+
});
22+
23+
it('initializes with the parent value', () => {
24+
expect(stompGuard.value).toBe(parentValue);
25+
});
26+
27+
it('accepts parent values when not dirty', () => {
28+
parentValue = 100; // parent change
29+
expect(stompGuard.value).toBe(100); // was accepted
30+
});
31+
32+
it('pushs changes to parent', () => {
33+
stompGuard.value = 100; // make dirty
34+
expect(stompGuard.value).toBe(100);
35+
expect(parentValue).toBe(100); // parent was updated
36+
});
37+
38+
it('ignores parent values when dirty', () => {
39+
stompGuard.value = 100; // make dirty
40+
expect(stompGuard.value).toBe(100);
41+
parentValue = 200; // parent change
42+
expect(stompGuard.value).toBe(100); // was ignored
43+
});
44+
45+
it('reverts new parent values when ignored', async () => {
46+
stompGuard.value = 100; // make dirty
47+
parentValue = 200; // parent change
48+
expect(stompGuard.value).toBe(100); // was ignored
49+
await tick();
50+
expect(parentValue).toBe(100); // parent was reverted
51+
});
52+
53+
it('starts accepting parent changes again after a flush', () => {
54+
stompGuard.value = 100; // make dirty
55+
stompGuard.commitAndUnlock();
56+
parentValue = 200; // parent change
57+
expect(stompGuard.value).toBe(200); // was accepted
58+
});
59+
60+
it('does NOT guard against stomping deep changes, because StompGuard can\'t detect them', async () => {
61+
let parentObjValue = $state({value: 42});
62+
let objStompGuard: StompGuard<{value: number}>;
63+
const cleanup = $effect.root(() => {
64+
objStompGuard = new StompGuard(() => parentObjValue, (value) => parentObjValue = value);
65+
});
66+
objStompGuard!.value.value = 100; // deep change from child
67+
expect(objStompGuard!.value.value).toBe(100);
68+
await tick();
69+
parentObjValue.value = 200; // parent change
70+
expect(objStompGuard!.value.value).toBe(200); // stomped the deep change
71+
cleanup();
72+
});
73+
74+
it('keeps subscribers up to date when it becomes dirty', async () => {
75+
const parentValue = $state(42);
76+
let stompGuard: StompGuard<number>;
77+
let derivedValue: number | undefined = undefined;
78+
const cleanup = $effect.root(() => {
79+
stompGuard = new StompGuard(
80+
() => parentValue,
81+
/* no-op to ensure subscribers are actually using the guarded value */
82+
(_value) => { });
83+
watch(() => stompGuard.value, (newValue) => {
84+
derivedValue = newValue;
85+
});
86+
});
87+
await tick(); // let effects run
88+
expect(derivedValue).toBe(42); // Ensure the derived value is subscribed
89+
90+
stompGuard!.value = 100; // make dirty/update
91+
await tick();
92+
expect(derivedValue).toBe(100); // derived value is listening to the guard value
93+
cleanup();
94+
});
95+
});

0 commit comments

Comments
 (0)