@cosmicdrift/kumiko-headless 0.1.0

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.
@@ -0,0 +1,75 @@
1
+ import type { ZodError, ZodIssue } from "zod";
2
+ import type { FieldIssue } from "../dispatcher";
3
+
4
+ // Translates a ZodError into the same FieldIssue shape the server emits via
5
+ // its own zod-bridge (packages/framework/src/errors/zod-bridge.ts). The two
6
+ // bridges stay in sync on purpose: a field failing zod on the client reads
7
+ // identically to the same field failing zod on the server, so the UI's
8
+ // error-display path doesn't branch on provenance.
9
+ //
10
+ // Duplicated here (not imported) so ui-core remains free of
11
+ // @cosmicdrift/kumiko-framework — this package has to bundle for browsers and Expo,
12
+ // where a server-oriented framework dep would balloon bundle size and drag
13
+ // in Node-only modules (postgres, drizzle, bullmq) even if only types are
14
+ // consumed.
15
+ //
16
+ // Keep this list in sync with the server-side mirror — Zod version bumps
17
+ // tend to introduce new param keys, and the server's classes.test.ts is
18
+ // what catches them. A value added there should be added here too.
19
+ const ISSUE_PARAM_KEYS = [
20
+ "minimum",
21
+ "maximum",
22
+ "expected",
23
+ "received",
24
+ "type",
25
+ "inclusive",
26
+ "exact",
27
+ "keys",
28
+ // Zod 4 additions
29
+ "format",
30
+ "divisor",
31
+ "values",
32
+ "pattern",
33
+ ] as const;
34
+
35
+ // Flat FieldIssue list — same shape as server-side ValidationError.fields.
36
+ // The form-controller groups this by `path` for its snapshot's errors map.
37
+ export function zodErrorToFieldIssues(error: ZodError): FieldIssue[] {
38
+ return error.issues.map<FieldIssue>((issue) => {
39
+ const params = extractIssueParams(issue);
40
+ return {
41
+ // Empty-path issues (top-level object/array failures) use "(root)" so
42
+ // the form-controller has a stable key to show "form itself is
43
+ // malformed" errors under — matches what the server does.
44
+ path: issue.path.map(String).join(".") || "(root)",
45
+ code: issue.code,
46
+ i18nKey: `errors.validation.${issue.code}`,
47
+ ...(params && { params }),
48
+ };
49
+ });
50
+ }
51
+
52
+ function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
53
+ const out: Record<string, unknown> = {};
54
+ const bag = issue as unknown as Record<string, unknown>; // @cast-boundary zod-issue
55
+ for (const key of ISSUE_PARAM_KEYS) {
56
+ if (bag[key] !== undefined) out[key] = bag[key];
57
+ }
58
+ return Object.keys(out).length > 0 ? out : undefined;
59
+ }
60
+
61
+ // Groups a flat FieldIssue list into the `errors` map carried by a
62
+ // FormSnapshot. A field with multiple issues (e.g. both "required" and
63
+ // "min") keeps both — UIs that only render the first can pick the first
64
+ // element, those that want a full list have it.
65
+ export function groupIssuesByPath(
66
+ issues: readonly FieldIssue[],
67
+ ): Record<string, readonly FieldIssue[]> {
68
+ const out: Record<string, FieldIssue[]> = {};
69
+ for (const issue of issues) {
70
+ const bucket = out[issue.path];
71
+ if (bucket) bucket.push(issue);
72
+ else out[issue.path] = [issue];
73
+ }
74
+ return out;
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type { ParsedRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ // Re-Export aus framework/ui-types damit Renderer-Code denselben
3
+ // Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
4
+ export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ export type {
6
+ AssetResolution,
7
+ AssetResolveContext,
8
+ AssetResolver,
9
+ BadgeProps,
10
+ ButtonProps,
11
+ CardProps,
12
+ DatePickerProps,
13
+ IconProps,
14
+ LocaleResolver,
15
+ ModalProps,
16
+ NumberInputProps,
17
+ PrimitiveCommonProps,
18
+ PrimitivesContract,
19
+ SelectOption,
20
+ SelectProps,
21
+ TextInputProps,
22
+ ToastIntent,
23
+ ToastProps,
24
+ ToggleProps,
25
+ } from "./contracts";
26
+ export type {
27
+ BatchResult,
28
+ Command,
29
+ Dispatcher,
30
+ DispatcherError,
31
+ DispatcherStatus,
32
+ FieldIssue,
33
+ PendingFile,
34
+ PendingWrite,
35
+ QueryOpts,
36
+ QueryResult,
37
+ WriteOpts,
38
+ WriteResult,
39
+ } from "./dispatcher";
40
+ export type {
41
+ FieldConditionPredicate,
42
+ FieldConditions,
43
+ FieldConditionValue,
44
+ FieldState,
45
+ FormController,
46
+ FormControllerOptions,
47
+ FormSnapshot,
48
+ FormValues,
49
+ SubmitConfig,
50
+ SubmitPayloadMode,
51
+ SubmitResult,
52
+ } from "./form";
53
+ export { createFormController } from "./form";
54
+ export type {
55
+ NavDefinition,
56
+ NavNode,
57
+ NavRegistrySlice,
58
+ NavTree,
59
+ ResolveNavigationOptions,
60
+ } from "./nav";
61
+ export { resolveNavigation } from "./nav";
62
+ export type { Store, WritableStore } from "./store";
63
+ export { createStore, shallowEqual } from "./store";
64
+ export type {
65
+ ComputeEditViewModelInput,
66
+ ComputeListViewModelInput,
67
+ EditFieldSpec,
68
+ EditFieldViewModel,
69
+ EditSectionSpec,
70
+ EditSectionViewModel,
71
+ EditViewModel,
72
+ FieldConditionCtx,
73
+ FieldRenderer,
74
+ ListColumnSpec,
75
+ ListColumnViewModel,
76
+ ListRowViewModel,
77
+ ListViewModel,
78
+ ScreenSlots,
79
+ Translate,
80
+ } from "./view-model";
81
+ export { computeEditViewModel, computeListViewModel, fieldLabelKey } from "./view-model";
@@ -0,0 +1,202 @@
1
+ import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { describe, expect, test } from "vitest";
3
+ import { resolveNavigation } from "../resolve";
4
+ import type { NavRegistrySlice } from "../types";
5
+
6
+ // Builds the minimal NavRegistrySlice resolveNavigation consumes from a
7
+ // flat NavDefinition list. Shape matches what the framework registry
8
+ // exposes: `topLevel` collects entries without a parent, `byParent`
9
+ // looks up children by qualified parent-id. Ids in the list MUST already
10
+ // be qualified ("feature:nav:short") — that's what the registry hands
11
+ // back, so tests here mirror it 1:1.
12
+ function buildSource(navs: readonly NavDefinition[]): NavRegistrySlice {
13
+ const byParent = new Map<string, NavDefinition[]>();
14
+ const topLevel: NavDefinition[] = [];
15
+ for (const nav of navs) {
16
+ if (nav.parent) {
17
+ const bucket = byParent.get(nav.parent);
18
+ if (bucket) bucket.push(nav);
19
+ else byParent.set(nav.parent, [nav]);
20
+ } else {
21
+ topLevel.push(nav);
22
+ }
23
+ }
24
+ return {
25
+ topLevel,
26
+ byParent: (qn) => byParent.get(qn) ?? [],
27
+ };
28
+ }
29
+
30
+ const userAdmin = { id: "u-admin", roles: ["Admin"] };
31
+ const userStandard = { id: "u-std", roles: ["User"] };
32
+
33
+ describe("resolveNavigation", () => {
34
+ test("flat list: all top-level entries become root nodes", () => {
35
+ const source = buildSource([
36
+ { id: "orders:nav:list", label: "Orders", screen: "orders:screen:order-list" },
37
+ { id: "orders:nav:dashboard", label: "Home" },
38
+ ]);
39
+
40
+ const tree = resolveNavigation({ source });
41
+
42
+ expect(tree).toHaveLength(2);
43
+ expect(tree.map((n) => n.qualifiedName).sort()).toEqual([
44
+ "orders:nav:dashboard",
45
+ "orders:nav:list",
46
+ ]);
47
+ expect(tree.every((n) => n.children.length === 0)).toBe(true);
48
+ });
49
+
50
+ test("parent refs assemble the tree — one level deep", () => {
51
+ const source = buildSource([
52
+ { id: "app:nav:ops", label: "Operations" },
53
+ {
54
+ id: "app:nav:ops-orders",
55
+ label: "Orders",
56
+ parent: "app:nav:ops",
57
+ screen: "orders:screen:order-list",
58
+ },
59
+ {
60
+ id: "app:nav:ops-shipments",
61
+ label: "Shipments",
62
+ parent: "app:nav:ops",
63
+ screen: "ship:screen:ship-list",
64
+ },
65
+ ]);
66
+
67
+ const tree = resolveNavigation({ source });
68
+
69
+ expect(tree).toHaveLength(1);
70
+ const ops = tree[0];
71
+ expect(ops?.qualifiedName).toBe("app:nav:ops");
72
+ expect(ops?.children).toHaveLength(2);
73
+ expect(ops?.children.map((c) => c.qualifiedName).sort()).toEqual([
74
+ "app:nav:ops-orders",
75
+ "app:nav:ops-shipments",
76
+ ]);
77
+ });
78
+
79
+ test("sort order: `order` ASC, ties broken by qualifiedName alphabetic", () => {
80
+ const source = buildSource([
81
+ { id: "app:nav:alpha", label: "A", order: 10 },
82
+ { id: "app:nav:beta", label: "B", order: 5 },
83
+ { id: "app:nav:charlie", label: "C", order: 5 }, // same order as beta
84
+ ]);
85
+
86
+ const tree = resolveNavigation({ source });
87
+
88
+ // beta (order=5) and charlie (order=5) first, both alphabetical, then alpha
89
+ expect(tree.map((n) => n.qualifiedName)).toEqual([
90
+ "app:nav:beta",
91
+ "app:nav:charlie",
92
+ "app:nav:alpha",
93
+ ]);
94
+ });
95
+
96
+ test("access: role-gated entries drop out for non-matching users", () => {
97
+ const source = buildSource([
98
+ { id: "app:nav:public", label: "Public" },
99
+ { id: "app:nav:admin-only", label: "Admin Area", access: { roles: ["Admin"] } },
100
+ ]);
101
+
102
+ const adminTree = resolveNavigation({ source, user: userAdmin });
103
+ expect(adminTree.map((n) => n.qualifiedName).sort()).toEqual([
104
+ "app:nav:admin-only",
105
+ "app:nav:public",
106
+ ]);
107
+
108
+ const stdTree = resolveNavigation({ source, user: userStandard });
109
+ expect(stdTree.map((n) => n.qualifiedName)).toEqual(["app:nav:public"]);
110
+ });
111
+
112
+ test("access: openToAll bypasses user-role check (matches handler-access semantic)", () => {
113
+ const source = buildSource([
114
+ { id: "app:nav:help", label: "Help", access: { openToAll: true } },
115
+ ]);
116
+
117
+ // Anonymous sees it.
118
+ expect(resolveNavigation({ source })).toHaveLength(1);
119
+ // Standard user sees it.
120
+ expect(resolveNavigation({ source, user: userStandard })).toHaveLength(1);
121
+ });
122
+
123
+ test("access: no user + role-gated entry → dropped (anonymous can't satisfy roles)", () => {
124
+ const source = buildSource([
125
+ { id: "app:nav:gated", label: "Gated", access: { roles: ["Admin"] } },
126
+ ]);
127
+
128
+ expect(resolveNavigation({ source })).toHaveLength(0);
129
+ });
130
+
131
+ test("hidden parent hides descendants (no orphaned children)", () => {
132
+ // "Reports" is Admin-only; the child "Sales Report" has no access rule
133
+ // (would be publicly visible by itself). With the parent gone, the
134
+ // child drops too — the resolver never recurses into a hidden node,
135
+ // so a lone link without its containing group can't appear.
136
+ const source = buildSource([
137
+ { id: "app:nav:reports", label: "Reports", access: { roles: ["Admin"] } },
138
+ {
139
+ id: "app:nav:sales-report",
140
+ label: "Sales",
141
+ parent: "app:nav:reports",
142
+ screen: "reports:screen:sales",
143
+ },
144
+ ]);
145
+
146
+ const tree = resolveNavigation({ source, user: userStandard });
147
+ expect(tree).toHaveLength(0);
148
+ });
149
+
150
+ test("entries pass through label/icon/screen verbatim — renderer translates label", () => {
151
+ const source = buildSource([
152
+ {
153
+ id: "app:nav:orders",
154
+ label: "orders:i18n:nav.orders",
155
+ icon: "package",
156
+ screen: "orders:screen:list",
157
+ order: 1,
158
+ },
159
+ ]);
160
+
161
+ const [node] = resolveNavigation({ source });
162
+ expect(node?.label).toBe("orders:i18n:nav.orders");
163
+ expect(node?.icon).toBe("package");
164
+ expect(node?.screen).toBe("orders:screen:list");
165
+ expect(node?.order).toBe(1);
166
+ });
167
+
168
+ test("order default is 0 on the NavNode when not provided", () => {
169
+ const source = buildSource([{ id: "app:nav:x", label: "X" }]);
170
+ expect(resolveNavigation({ source })[0]?.order).toBe(0);
171
+ });
172
+
173
+ test("dangling parent ref → child never reached (boot-validator should catch upstream)", () => {
174
+ // "orphan" has parent="missing"; "missing" is not registered.
175
+ // Because the walk is top-down, orphan sits only in byParent("missing")
176
+ // and byParent is never called for "missing" (it's not in topLevel,
177
+ // it's not reachable). So the child naturally drops.
178
+ const source = buildSource([
179
+ { id: "app:nav:orphan", label: "Orphan", parent: "app:nav:missing" },
180
+ ]);
181
+
182
+ expect(resolveNavigation({ source })).toHaveLength(0);
183
+ });
184
+
185
+ test("empty registry → empty tree", () => {
186
+ const source: NavRegistrySlice = { topLevel: [], byParent: () => [] };
187
+ expect(resolveNavigation({ source })).toEqual([]);
188
+ });
189
+
190
+ test("deeper nesting: three levels compose correctly", () => {
191
+ const source = buildSource([
192
+ { id: "a:nav:root", label: "Root" },
193
+ { id: "a:nav:mid", label: "Mid", parent: "a:nav:root" },
194
+ { id: "a:nav:leaf", label: "Leaf", parent: "a:nav:mid", screen: "a:screen:x" },
195
+ ]);
196
+
197
+ const tree = resolveNavigation({ source });
198
+ expect(tree[0]?.qualifiedName).toBe("a:nav:root");
199
+ expect(tree[0]?.children[0]?.qualifiedName).toBe("a:nav:mid");
200
+ expect(tree[0]?.children[0]?.children[0]?.qualifiedName).toBe("a:nav:leaf");
201
+ });
202
+ });
@@ -0,0 +1,8 @@
1
+ export { resolveNavigation } from "./resolve";
2
+ export type {
3
+ NavDefinition,
4
+ NavNode,
5
+ NavRegistrySlice,
6
+ NavTree,
7
+ ResolveNavigationOptions,
8
+ } from "./types";
@@ -0,0 +1,77 @@
1
+ import type { AccessRule, NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { NavNode, NavTree, ResolveNavigationOptions } from "./types";
3
+
4
+ // Assembles the renderable nav tree from the registry's pre-grouped
5
+ // indexes (topLevel + byParent). Walks top-down: each node is
6
+ // access-checked; a hidden parent drops its entire subtree implicitly
7
+ // because we never recurse into it. Siblings sort by `order` (ascending,
8
+ // default 0), tie-broken by qualified name so renders stay deterministic
9
+ // across registry iteration orders.
10
+ //
11
+ // Pure — same inputs produce the same tree. The renderer memoizes the
12
+ // result and only recomputes when the registry changes (rare — boot
13
+ // only) or the user's roles change (logout/login, tenant-switch).
14
+ export function resolveNavigation(options: ResolveNavigationOptions): NavTree {
15
+ const { source, user } = options;
16
+
17
+ function build(entry: NavDefinition): NavNode | null {
18
+ if (!userCanSee(entry.access, user)) return null;
19
+ // `entry.id` is already the qualified name — the registry stores
20
+ // it that way. No reverse-index lookup needed.
21
+ const children: NavNode[] = [];
22
+ for (const child of source.byParent(entry.id)) {
23
+ const node = build(child);
24
+ if (node !== null) children.push(node);
25
+ }
26
+ children.sort(bySortKey);
27
+ return {
28
+ qualifiedName: entry.id,
29
+ label: entry.label,
30
+ order: entry.order ?? 0,
31
+ children,
32
+ ...(entry.icon !== undefined && { icon: entry.icon }),
33
+ ...(entry.screen !== undefined && { screen: entry.screen }),
34
+ };
35
+ }
36
+
37
+ const roots: NavNode[] = [];
38
+ for (const entry of source.topLevel) {
39
+ const node = build(entry);
40
+ if (node !== null) roots.push(node);
41
+ }
42
+ roots.sort(bySortKey);
43
+ return roots;
44
+ }
45
+
46
+ function bySortKey(a: NavNode, b: NavNode): number {
47
+ // Primary key: `order` ascending. Secondary: qualifiedName alphabetic,
48
+ // which is a stable fallback — the registry-iteration order isn't
49
+ // guaranteed across boots, so tied-order entries would otherwise
50
+ // shuffle between renders.
51
+ if (a.order !== b.order) return a.order - b.order;
52
+ return a.qualifiedName.localeCompare(b.qualifiedName);
53
+ }
54
+
55
+ // Access evaluator. Duplicated minimal logic instead of importing
56
+ // hasAccess from @cosmicdrift/kumiko-framework/engine at runtime — that module pulls
57
+ // in server-side deps (tenant-db, ownership-evaluator) and would break
58
+ // ui-core's bundle-purity guarantee. Only roles + openToAll are checked;
59
+ // ownership-level row-filtering is a server-side concern and doesn't
60
+ // apply to navigation entries (they're a menu, not a dataset).
61
+ function userCanSee(
62
+ access: AccessRule | undefined,
63
+ user: ResolveNavigationOptions["user"],
64
+ ): boolean {
65
+ // No rule = always visible — matches the framework's "engine stays
66
+ // un-opinionated about who sees what" stance in the nav docs.
67
+ if (!access) return true;
68
+ if ("openToAll" in access && access.openToAll) return true;
69
+ if (!user) return false; // anonymous can't match a role-gated rule
70
+ if ("roles" in access) {
71
+ const allowed = access.roles;
72
+ for (const role of user.roles) {
73
+ if (allowed.includes(role)) return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
@@ -0,0 +1,61 @@
1
+ import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+
3
+ // A single resolved nav entry as the renderer consumes it. Labels are NOT
4
+ // translated yet — nav can be used in SSR contexts where the locale isn't
5
+ // known at resolve time; the renderer runs `label` through
6
+ // LocaleResolver.translate() when it draws the sidebar/topbar item.
7
+ //
8
+ // Identity: `qualifiedName` is the same QN the registry uses
9
+ // ("{feature}:nav:{id}"). Renderers key off this for active-route tracking
10
+ // and for `parent` matching in screen extensions (M4).
11
+ export type NavNode = {
12
+ readonly qualifiedName: string;
13
+ readonly label: string;
14
+ readonly icon?: string;
15
+ readonly screen?: string;
16
+ readonly order: number;
17
+ readonly children: readonly NavNode[];
18
+ };
19
+
20
+ // The tree returned by resolveNavigation. Empty when the user can see no
21
+ // entries — renderer draws a fallback ("no navigation available" or just
22
+ // a blank sidebar, app decision).
23
+ export type NavTree = readonly NavNode[];
24
+
25
+ // Minimal registry surface resolveNavigation reads from. Deliberately
26
+ // narrower than the full Registry — keeps the resolver test-friendly
27
+ // (callers can stub with a two-field plain object) and pins the
28
+ // coupling to just the two index-accessors the resolver actually uses:
29
+ //
30
+ // - `topLevel` : roots list (entries with no parent). Pre-grouped in
31
+ // the registry — resolver starts its walk here.
32
+ // - `byParent` : O(1) children lookup by parent qualified name. The
33
+ // walk descends into this for each node; access-gating
34
+ // happens top-down, so a hidden parent implicitly
35
+ // hides the whole subtree.
36
+ //
37
+ // Note: the registry stores each NavDefinition with its `id` already
38
+ // qualified ("feature:nav:short"); resolveNavigation reads that directly.
39
+ //
40
+ // Production callers pass:
41
+ // { topLevel: registry.getTopLevelNavs(),
42
+ // byParent: (qn) => registry.getNavsByParent(qn) }
43
+ export type NavRegistrySlice = {
44
+ readonly topLevel: readonly NavDefinition[];
45
+ readonly byParent: (parentQualifiedName: string) => readonly NavDefinition[];
46
+ };
47
+
48
+ // Options passed to resolveNavigation.
49
+ export type ResolveNavigationOptions = {
50
+ readonly source: NavRegistrySlice;
51
+ // Current session user. Access-rule enforcement compares
52
+ // rule.roles against user.roles. When user is undefined, ONLY
53
+ // entries with access.openToAll === true (or no access declared)
54
+ // survive — matches the "anonymous visit" semantic.
55
+ readonly user?: {
56
+ readonly id: string;
57
+ readonly roles: readonly string[];
58
+ };
59
+ };
60
+
61
+ export type { NavDefinition };
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createStore } from "../create-store";
3
+
4
+ describe("createStore — snapshot & setState", () => {
5
+ test("getSnapshot returns the initial value", () => {
6
+ const store = createStore({ count: 0 });
7
+ expect(store.getSnapshot()).toEqual({ count: 0 });
8
+ });
9
+
10
+ test("setState with a value updates the snapshot", () => {
11
+ const store = createStore({ count: 0 });
12
+ store.setState({ count: 5 });
13
+ expect(store.getSnapshot()).toEqual({ count: 5 });
14
+ });
15
+
16
+ test("setState with a reducer receives the current snapshot", () => {
17
+ const store = createStore({ count: 3 });
18
+ store.setState((prev) => ({ count: prev.count + 1 }));
19
+ expect(store.getSnapshot()).toEqual({ count: 4 });
20
+ });
21
+
22
+ test("getSnapshot returns stable reference when value unchanged (Object.is gate)", () => {
23
+ const initial = { count: 0 };
24
+ const store = createStore(initial);
25
+ const before = store.getSnapshot();
26
+ store.setState(initial); // same reference
27
+ expect(store.getSnapshot()).toBe(before);
28
+ });
29
+ });
30
+
31
+ describe("createStore — subscribe & notify", () => {
32
+ test("subscribed listener fires on setState", () => {
33
+ const store = createStore({ count: 0 });
34
+ const listener = vi.fn();
35
+ store.subscribe(listener);
36
+
37
+ store.setState({ count: 1 });
38
+
39
+ expect(listener).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ test("multiple listeners all fire on setState", () => {
43
+ const store = createStore({ count: 0 });
44
+ const a = vi.fn();
45
+ const b = vi.fn();
46
+ store.subscribe(a);
47
+ store.subscribe(b);
48
+
49
+ store.setState({ count: 1 });
50
+
51
+ expect(a).toHaveBeenCalledTimes(1);
52
+ expect(b).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ test("unsubscribe stops further notifications", () => {
56
+ const store = createStore({ count: 0 });
57
+ const listener = vi.fn();
58
+ const unsub = store.subscribe(listener);
59
+
60
+ store.setState({ count: 1 });
61
+ unsub();
62
+ store.setState({ count: 2 });
63
+
64
+ expect(listener).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ test("Object.is-equal setState does NOT notify listeners", () => {
68
+ const store = createStore({ count: 0 });
69
+ const same = store.getSnapshot();
70
+ const listener = vi.fn();
71
+ store.subscribe(listener);
72
+
73
+ // Same reference — gate blocks notification.
74
+ store.setState(same);
75
+ // Primitive-equal setState on primitive-valued store also no-ops.
76
+ const primStore = createStore(42);
77
+ const primListener = vi.fn();
78
+ primStore.subscribe(primListener);
79
+ primStore.setState(42);
80
+
81
+ expect(listener).not.toHaveBeenCalled();
82
+ expect(primListener).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("setState inside reducer that returns same ref does NOT notify", () => {
86
+ const store = createStore({ count: 0 });
87
+ const listener = vi.fn();
88
+ store.subscribe(listener);
89
+
90
+ store.setState((prev) => prev); // reducer returns same ref
91
+
92
+ expect(listener).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ describe("createStore — reentrancy safety", () => {
97
+ test("listener unsubscribing itself during callback does not break iteration", () => {
98
+ const store = createStore({ count: 0 });
99
+ const order: string[] = [];
100
+
101
+ const unsubA = store.subscribe(() => {
102
+ order.push("a");
103
+ unsubA(); // self-unsubscribe mid-loop
104
+ });
105
+ store.subscribe(() => {
106
+ order.push("b");
107
+ });
108
+
109
+ store.setState({ count: 1 });
110
+
111
+ // Both fire on this cycle; A must not prevent B from running.
112
+ expect(order).toEqual(["a", "b"]);
113
+
114
+ // A is gone; only B fires on the next cycle.
115
+ order.length = 0;
116
+ store.setState({ count: 2 });
117
+ expect(order).toEqual(["b"]);
118
+ });
119
+
120
+ test("listener unsubscribing ANOTHER listener mid-loop does not re-invoke it", () => {
121
+ const store = createStore({ count: 0 });
122
+ const order: string[] = [];
123
+
124
+ let unsubB: (() => void) | null = null;
125
+
126
+ store.subscribe(() => {
127
+ order.push("a");
128
+ unsubB?.(); // kill B before its turn
129
+ });
130
+ unsubB = store.subscribe(() => {
131
+ order.push("b");
132
+ });
133
+
134
+ store.setState({ count: 1 });
135
+
136
+ // B was unsubscribed by A before iteration reached it.
137
+ expect(order).toEqual(["a"]);
138
+ });
139
+ });