@cosmicdrift/kumiko-renderer-web 0.243.2 → 0.243.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.243.
|
|
3
|
+
"version": "0.243.3",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.243.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.243.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.243.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.243.3",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.243.3",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.243.3",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.243.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.243.3"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -8,13 +8,22 @@
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
10
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionSectionProps,
|
|
13
|
+
FeatureSchema,
|
|
14
|
+
NavApi,
|
|
15
|
+
NavTarget,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
12
17
|
import {
|
|
13
18
|
DispatcherProvider,
|
|
14
19
|
ExtensionSectionsProvider,
|
|
15
20
|
KumikoScreen,
|
|
16
21
|
NavProvider,
|
|
22
|
+
useDispatcher,
|
|
23
|
+
usePrimitives,
|
|
17
24
|
} from "@cosmicdrift/kumiko-renderer";
|
|
25
|
+
import type { ReactNode } from "react";
|
|
26
|
+
import { BareFormProvider } from "../primitives";
|
|
18
27
|
import { act, createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
19
28
|
|
|
20
29
|
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
@@ -613,3 +622,211 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
|
|
|
613
622
|
expect(screen.queryByTestId("field-userId")).toBeNull();
|
|
614
623
|
});
|
|
615
624
|
});
|
|
625
|
+
|
|
626
|
+
// fw#2312 mounts extension sections (ExtensionSectionMount, render-edit.tsx)
|
|
627
|
+
// INSIDE RenderEdit's own host <form testId="render-edit-form"> (render-edit.tsx:1059).
|
|
628
|
+
// A section that renders its own <form> — the pattern ChangeEmailSection/
|
|
629
|
+
// ChangePasswordSection used before fw#2703 via BareFormProvider — used to
|
|
630
|
+
// produce two nested <form> elements, invalid DOM that let real browsers
|
|
631
|
+
// silently degrade the inner submit into a native GET navigation of the
|
|
632
|
+
// OUTER form (offlot-app e2e, currentPassword leaked into the URL). fw#2705
|
|
633
|
+
// fixes the cause in DefaultForm (primitives/index.tsx): it degrades to a
|
|
634
|
+
// <div> whenever InsideFormContext says it is already nested in a form, and
|
|
635
|
+
// intercepts a click on its own submit button (onClickCapture +
|
|
636
|
+
// preventDefault) instead of relying on the browser to associate the button
|
|
637
|
+
// with a form. The tests below assert that structurally (exactly one
|
|
638
|
+
// <form>) and prove the degraded section's own submit still reaches its
|
|
639
|
+
// dispatcher, without depending on the button-to-form association the
|
|
640
|
+
// removed nested <form> used to rely on.
|
|
641
|
+
describe("KumikoScreen / projectionDetail extension section with its own <form> (fw#2312/fw#2705 nested-form regression)", () => {
|
|
642
|
+
function FormExtensionSection({ entityId }: ExtensionSectionProps): ReactNode {
|
|
643
|
+
const dispatcher = useDispatcher();
|
|
644
|
+
const { Form, Button } = usePrimitives();
|
|
645
|
+
const onSubmit = (): void => {
|
|
646
|
+
void dispatcher.write("sessions:write:user-session:update", {
|
|
647
|
+
id: entityId,
|
|
648
|
+
note: "updated",
|
|
649
|
+
});
|
|
650
|
+
};
|
|
651
|
+
return (
|
|
652
|
+
<BareFormProvider>
|
|
653
|
+
<Form
|
|
654
|
+
testId="nested-section-form"
|
|
655
|
+
onSubmit={onSubmit}
|
|
656
|
+
actions={
|
|
657
|
+
<Button type="submit" testId="nested-section-submit">
|
|
658
|
+
Save note
|
|
659
|
+
</Button>
|
|
660
|
+
}
|
|
661
|
+
>
|
|
662
|
+
<div data-testid="nested-section-marker" />
|
|
663
|
+
</Form>
|
|
664
|
+
</BareFormProvider>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
test("mounted through KumikoScreen -> ProjectionDetailBody -> RenderEdit, the section's own form degrades to a <div> so only one <form> lands in the DOM", async () => {
|
|
669
|
+
const extensionScreen: ProjectionDetailScreenDefinition = {
|
|
670
|
+
...detailScreen,
|
|
671
|
+
layout: {
|
|
672
|
+
sections: [
|
|
673
|
+
...detailScreen.layout.sections,
|
|
674
|
+
{
|
|
675
|
+
kind: "extension",
|
|
676
|
+
title: "Note",
|
|
677
|
+
component: { react: { __component: "FormExtensionSection" } },
|
|
678
|
+
entityName: "user-session",
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
const extensionSchema: FeatureSchema = {
|
|
684
|
+
featureName: "sessions",
|
|
685
|
+
entities: {},
|
|
686
|
+
screens: [extensionScreen],
|
|
687
|
+
};
|
|
688
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
689
|
+
query: (async () => ({
|
|
690
|
+
isSuccess: true,
|
|
691
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
692
|
+
})) as unknown as Dispatcher["query"],
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const { container } = render(
|
|
696
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
697
|
+
<ExtensionSectionsProvider value={{ FormExtensionSection }}>
|
|
698
|
+
<KumikoScreen
|
|
699
|
+
schema={extensionSchema}
|
|
700
|
+
qn="sessions:screen:session-detail"
|
|
701
|
+
entityId="sess-1"
|
|
702
|
+
/>
|
|
703
|
+
</ExtensionSectionsProvider>
|
|
704
|
+
</DispatcherProvider>,
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
await waitFor(() => screen.getByTestId("nested-section-form"));
|
|
708
|
+
// Structural proof of the fix: RenderEdit renders one
|
|
709
|
+
// <form testId="render-edit-form"> around the whole screen. The
|
|
710
|
+
// nested section's own <Form> (still wrapped in BareFormProvider) must
|
|
711
|
+
// degrade to a <div> (FormRoot, primitives/index.tsx) instead of
|
|
712
|
+
// adding a second <form>.
|
|
713
|
+
expect(container.querySelectorAll("form")).toHaveLength(1);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("clicking the nested section's own submit button dispatches its write, even though FormRoot degraded its <Form> to a <div>", async () => {
|
|
717
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
718
|
+
const extensionScreen: ProjectionDetailScreenDefinition = {
|
|
719
|
+
...detailScreen,
|
|
720
|
+
layout: {
|
|
721
|
+
sections: [
|
|
722
|
+
...detailScreen.layout.sections,
|
|
723
|
+
{
|
|
724
|
+
kind: "extension",
|
|
725
|
+
title: "Note",
|
|
726
|
+
component: { react: { __component: "FormExtensionSection" } },
|
|
727
|
+
entityName: "user-session",
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
const extensionSchema: FeatureSchema = {
|
|
733
|
+
featureName: "sessions",
|
|
734
|
+
entities: {},
|
|
735
|
+
screens: [extensionScreen],
|
|
736
|
+
};
|
|
737
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
738
|
+
query: (async () => ({
|
|
739
|
+
isSuccess: true,
|
|
740
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
741
|
+
})) as unknown as Dispatcher["query"],
|
|
742
|
+
write: (async (type: string, payload: unknown) => {
|
|
743
|
+
writes.push({ type, payload });
|
|
744
|
+
return { isSuccess: true, data: {} };
|
|
745
|
+
}) as unknown as Dispatcher["write"],
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
render(
|
|
749
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
750
|
+
<ExtensionSectionsProvider value={{ FormExtensionSection }}>
|
|
751
|
+
<KumikoScreen
|
|
752
|
+
schema={extensionSchema}
|
|
753
|
+
qn="sessions:screen:session-detail"
|
|
754
|
+
entityId="sess-1"
|
|
755
|
+
/>
|
|
756
|
+
</ExtensionSectionsProvider>
|
|
757
|
+
</DispatcherProvider>,
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
await waitFor(() => screen.getByTestId("nested-section-submit"));
|
|
761
|
+
fireEvent.click(screen.getByTestId("nested-section-submit"));
|
|
762
|
+
|
|
763
|
+
await waitFor(() => {
|
|
764
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
765
|
+
});
|
|
766
|
+
expect(writes[0]).toEqual({
|
|
767
|
+
type: "sessions:write:user-session:update",
|
|
768
|
+
payload: { id: "sess-1", note: "updated" },
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Complements the dispatch test above: with the section's own <Form>
|
|
773
|
+
// degraded to a <div>, its submit button sits inside the SAME real <form>
|
|
774
|
+
// as render-edit-form. Without FormRoot's onClickCapture + preventDefault,
|
|
775
|
+
// activating that type="submit" button would fire the OUTER form's native
|
|
776
|
+
// submit instead of routing to the section's onSubmit — the exact fw#2705
|
|
777
|
+
// hazard the nested-<form> version produced, just surfacing here as a
|
|
778
|
+
// wrong dispatch target rather than a URL GET (the dispatch test above
|
|
779
|
+
// cannot tell the two apart: RenderEdit's own handleSubmit is a no-op for
|
|
780
|
+
// this readOnly projectionDetail screen either way).
|
|
781
|
+
test("clicking the nested section's own submit button does not also fire a native submit on the outer host <form>", async () => {
|
|
782
|
+
const extensionScreen: ProjectionDetailScreenDefinition = {
|
|
783
|
+
...detailScreen,
|
|
784
|
+
layout: {
|
|
785
|
+
sections: [
|
|
786
|
+
...detailScreen.layout.sections,
|
|
787
|
+
{
|
|
788
|
+
kind: "extension",
|
|
789
|
+
title: "Note",
|
|
790
|
+
component: { react: { __component: "FormExtensionSection" } },
|
|
791
|
+
entityName: "user-session",
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
const extensionSchema: FeatureSchema = {
|
|
797
|
+
featureName: "sessions",
|
|
798
|
+
entities: {},
|
|
799
|
+
screens: [extensionScreen],
|
|
800
|
+
};
|
|
801
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
802
|
+
query: (async () => ({
|
|
803
|
+
isSuccess: true,
|
|
804
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
805
|
+
})) as unknown as Dispatcher["query"],
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const { container } = render(
|
|
809
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
810
|
+
<ExtensionSectionsProvider value={{ FormExtensionSection }}>
|
|
811
|
+
<KumikoScreen
|
|
812
|
+
schema={extensionSchema}
|
|
813
|
+
qn="sessions:screen:session-detail"
|
|
814
|
+
entityId="sess-1"
|
|
815
|
+
/>
|
|
816
|
+
</ExtensionSectionsProvider>
|
|
817
|
+
</DispatcherProvider>,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
await waitFor(() => screen.getByTestId("nested-section-submit"));
|
|
821
|
+
const outerForm = container.querySelector('[data-testid="render-edit-form"]');
|
|
822
|
+
expect(outerForm).not.toBeNull();
|
|
823
|
+
let outerSubmitFired = false;
|
|
824
|
+
outerForm?.addEventListener("submit", () => {
|
|
825
|
+
outerSubmitFired = true;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
fireEvent.click(screen.getByTestId("nested-section-submit"));
|
|
829
|
+
|
|
830
|
+
expect(outerSubmitFired).toBe(false);
|
|
831
|
+
});
|
|
832
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -2063,6 +2063,59 @@ export function ScreenWidthProvider({
|
|
|
2063
2063
|
return <ScreenWidthContext.Provider value={width}>{children}</ScreenWidthContext.Provider>;
|
|
2064
2064
|
}
|
|
2065
2065
|
|
|
2066
|
+
// Extension sections that render their own <form> via BareFormProvider
|
|
2067
|
+
// (legacy custom-form pattern, e.g. ChangeEmailSection before fw#2703) land
|
|
2068
|
+
// inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
|
|
2069
|
+
// nested <form> elements are invalid DOM, and real browsers can silently
|
|
2070
|
+
// fall back to a native GET submit of the OUTER form, leaking field values
|
|
2071
|
+
// into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
|
|
2072
|
+
// a form avoids the nesting; the captured click still routes to THIS form's
|
|
2073
|
+
// onSubmit instead of activating the real ancestor <form>.
|
|
2074
|
+
function FormRoot({
|
|
2075
|
+
onSubmit,
|
|
2076
|
+
testId,
|
|
2077
|
+
className,
|
|
2078
|
+
children,
|
|
2079
|
+
}: {
|
|
2080
|
+
readonly onSubmit: FormProps["onSubmit"];
|
|
2081
|
+
readonly testId?: string;
|
|
2082
|
+
readonly className?: string;
|
|
2083
|
+
readonly children: ReactNode;
|
|
2084
|
+
}): ReactNode {
|
|
2085
|
+
if (useContext(InsideFormContext)) {
|
|
2086
|
+
return (
|
|
2087
|
+
<div
|
|
2088
|
+
onClickCapture={(e) => {
|
|
2089
|
+
// @cast-boundary dom-event-target: closest() needs an Element, and
|
|
2090
|
+
// click targets are always one in the browser/happy-dom.
|
|
2091
|
+
const submitButton = (e.target as HTMLElement).closest(
|
|
2092
|
+
"button[type=submit], button:not([type])",
|
|
2093
|
+
);
|
|
2094
|
+
if (submitButton === null) return;
|
|
2095
|
+
e.preventDefault();
|
|
2096
|
+
onSubmit();
|
|
2097
|
+
}}
|
|
2098
|
+
data-testid={testId}
|
|
2099
|
+
className={className}
|
|
2100
|
+
>
|
|
2101
|
+
{children}
|
|
2102
|
+
</div>
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
return (
|
|
2106
|
+
<form
|
|
2107
|
+
onSubmit={(e) => {
|
|
2108
|
+
e.preventDefault();
|
|
2109
|
+
onSubmit(e);
|
|
2110
|
+
}}
|
|
2111
|
+
data-testid={testId}
|
|
2112
|
+
className={className}
|
|
2113
|
+
>
|
|
2114
|
+
{children}
|
|
2115
|
+
</form>
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2066
2119
|
function DefaultForm({
|
|
2067
2120
|
onSubmit,
|
|
2068
2121
|
children,
|
|
@@ -2079,12 +2132,9 @@ function DefaultForm({
|
|
|
2079
2132
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
2080
2133
|
if (useContext(BareFormContext)) {
|
|
2081
2134
|
return (
|
|
2082
|
-
<
|
|
2083
|
-
onSubmit={
|
|
2084
|
-
|
|
2085
|
-
onSubmit(e);
|
|
2086
|
-
}}
|
|
2087
|
-
data-testid={testId}
|
|
2135
|
+
<FormRoot
|
|
2136
|
+
onSubmit={onSubmit}
|
|
2137
|
+
testId={testId}
|
|
2088
2138
|
className={cn(
|
|
2089
2139
|
"flex flex-col gap-4",
|
|
2090
2140
|
// Bare forms stack sections without a card; without a divider a
|
|
@@ -2099,7 +2149,7 @@ function DefaultForm({
|
|
|
2099
2149
|
{actions}
|
|
2100
2150
|
</div>
|
|
2101
2151
|
)}
|
|
2102
|
-
</
|
|
2152
|
+
</FormRoot>
|
|
2103
2153
|
);
|
|
2104
2154
|
}
|
|
2105
2155
|
|
|
@@ -2107,14 +2157,7 @@ function DefaultForm({
|
|
|
2107
2157
|
// between each other, muted action footer. Shell width defaults to full
|
|
2108
2158
|
// (same chrome as lists); pass width to narrow (auth-adjacent / dense).
|
|
2109
2159
|
return (
|
|
2110
|
-
<
|
|
2111
|
-
onSubmit={(e) => {
|
|
2112
|
-
e.preventDefault();
|
|
2113
|
-
onSubmit(e);
|
|
2114
|
-
}}
|
|
2115
|
-
data-testid={testId}
|
|
2116
|
-
className="flex flex-col w-full"
|
|
2117
|
-
>
|
|
2160
|
+
<FormRoot onSubmit={onSubmit} testId={testId} className="flex flex-col w-full">
|
|
2118
2161
|
<FormScreenShell {...(width !== undefined && { maxWidth: width })}>
|
|
2119
2162
|
{headerRegion !== undefined && (
|
|
2120
2163
|
<div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
|
|
@@ -2191,7 +2234,7 @@ function DefaultForm({
|
|
|
2191
2234
|
)}
|
|
2192
2235
|
</div>
|
|
2193
2236
|
</FormScreenShell>
|
|
2194
|
-
</
|
|
2237
|
+
</FormRoot>
|
|
2195
2238
|
);
|
|
2196
2239
|
}
|
|
2197
2240
|
|