janux 0.1.0 → 0.2.1

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,169 @@
1
+ import type { Manifest, ManifestTool } from '../manifest';
2
+ import type { JanuxBridge } from './bridge';
3
+ import { createNavigateTool } from './navigate-tool';
4
+
5
+ export interface WebMCPToolDescriptor {
6
+ name: string;
7
+ description?: string;
8
+ inputSchema?: Record<string, unknown>;
9
+ execute: (input: unknown) => unknown;
10
+ }
11
+
12
+ export interface ModelContext {
13
+ registerTool(tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): unknown;
14
+ provideContext?(context: { tools: WebMCPToolDescriptor[] }): unknown;
15
+ }
16
+
17
+ export interface ModelContextPolyfill extends ModelContext {
18
+ polyfilled: true;
19
+ /** Polyfill-only: enumerate registered tools (the real API keeps them internal). */
20
+ listTools(): WebMCPToolDescriptor[];
21
+ /** Polyfill-only: invoke a registered tool by name, gui-agent style. */
22
+ callTool(name: string, input?: unknown): Promise<unknown>;
23
+ }
24
+
25
+ export interface WebMCPHandle {
26
+ /** Re-reads the manifest and re-registers every tool. Runs automatically on SPA navigation. */
27
+ sync(): Promise<void>;
28
+ dispose(): void;
29
+ }
30
+
31
+ /**
32
+ * Spec-shaped stand-in for `document.modelContext` (WebMCP). Lets the same
33
+ * registration path — and any in-page agent or test — work in browsers that
34
+ * don't ship the API yet. Registrations honor AbortSignal like the real thing.
35
+ */
36
+ export function createModelContextPolyfill(): ModelContextPolyfill {
37
+ const tools = new Map<string, WebMCPToolDescriptor>();
38
+
39
+ const registerTool = (tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): void => {
40
+ if (options?.signal?.aborted) return;
41
+ tools.set(tool.name, tool);
42
+ options?.signal?.addEventListener('abort', () => {
43
+ if (tools.get(tool.name) === tool) tools.delete(tool.name);
44
+ });
45
+ };
46
+
47
+ return {
48
+ polyfilled: true,
49
+ registerTool,
50
+ provideContext({ tools: next }) {
51
+ tools.clear();
52
+ next.forEach((tool) => registerTool(tool));
53
+ },
54
+ listTools: () => [...tools.values()],
55
+ async callTool(name, input) {
56
+ const tool = tools.get(name);
57
+
58
+ if (!tool) throw new Error(`WebMCP polyfill: unknown tool "${name}"`);
59
+
60
+ return tool.execute(input);
61
+ },
62
+ };
63
+ }
64
+
65
+ /** The native context when the browser has one (Chrome 149+ behind a flag); the polyfill otherwise. */
66
+ function resolveModelContext(): ModelContext {
67
+ const doc = document as any;
68
+ const native = doc.modelContext ?? (navigator as any).modelContext;
69
+
70
+ if (native) return native;
71
+
72
+ return (doc.modelContext = createModelContextPolyfill());
73
+ }
74
+
75
+ /** The server knows the whole route (lazy islands, api() tools); unreachable → empty, fail-soft. */
76
+ async function routeTools(): Promise<ManifestTool[]> {
77
+ try {
78
+ const url = `/_janux/manifest?path=${encodeURIComponent(location.pathname)}`;
79
+ const response = await fetch(url, { headers: { accept: 'application/json' } });
80
+
81
+ if (!response.ok) return [];
82
+
83
+ return ((await response.json()) as Manifest).tools ?? [];
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ async function callServerTool(name: string, input: unknown): Promise<unknown> {
90
+ const response = await fetch(`/_janux/api/${name.slice('api.'.length)}`, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
93
+ body: JSON.stringify(input ?? {}),
94
+ });
95
+
96
+ return response.json();
97
+ }
98
+
99
+ function descriptorFor(tool: ManifestTool, bridge: JanuxBridge): WebMCPToolDescriptor {
100
+ const approval = tool.guard === 'confirm' ? ' Returns a proposal a human must approve.' : '';
101
+
102
+ return {
103
+ name: tool.name.replace(/[^\w-]/g, '_'),
104
+ description: `${tool.description ?? `Janux tool ${tool.name}`}${approval}`,
105
+ inputSchema: tool.input ?? { type: 'object', properties: {} },
106
+ async execute(input) {
107
+ const result = tool.name.startsWith('api.')
108
+ ? await callServerTool(tool.name, input)
109
+ : await bridge.call(tool.name, input);
110
+
111
+ return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] };
112
+ },
113
+ };
114
+ }
115
+
116
+ /** Route manifest first, live local manifest on top (its `ready` state is current). */
117
+ function mergedTools(bridge: JanuxBridge, remote: ManifestTool[]): ManifestTool[] {
118
+ const byName = new Map(remote.map((tool) => [tool.name, tool]));
119
+
120
+ bridge.manifest().tools.forEach((tool) => byName.set(tool.name, tool));
121
+
122
+ return [...byName.values()];
123
+ }
124
+
125
+ /**
126
+ * Zero-config WebMCP: registers every mounted tool with `document.modelContext`
127
+ * (polyfilled when absent) and keeps the registration in sync across SPA
128
+ * navigations. Chrome's DevTools WebMCP panel then shows the Janux surface as-is.
129
+ */
130
+ export function installWebMCP(bridge: JanuxBridge): WebMCPHandle {
131
+ const context = resolveModelContext();
132
+ let controller: AbortController | undefined;
133
+ let chain: Promise<void> = Promise.resolve();
134
+
135
+ const run = async (): Promise<void> => {
136
+ const tools = mergedTools(bridge, await routeTools());
137
+ // An app tool named `navigate` owns the name; the built-in steps aside.
138
+ const taken = tools.some((tool) => tool.name.replace(/[^\w-]/g, '_') === 'navigate');
139
+ const descriptors = [...(taken ? [] : [createNavigateTool()]), ...tools.map((tool) => descriptorFor(tool, bridge))];
140
+
141
+ controller?.abort();
142
+ controller = new AbortController();
143
+ for (const descriptor of descriptors) {
144
+ try {
145
+ await context.registerTool(descriptor, { signal: controller.signal });
146
+ } catch {
147
+ // One rejected registration (schema quirks, duplicate names…) must not drop the rest.
148
+ }
149
+ }
150
+ };
151
+
152
+ // Serialized like navigations: a re-sync never races the previous one's aborts.
153
+ const sync = (): Promise<void> => (chain = chain.then(run));
154
+
155
+ const onNavigate = (event: Event): void => {
156
+ if ((event as CustomEvent).detail?.phase === 'after') void sync();
157
+ };
158
+
159
+ document.addEventListener('janux:navigate', onNavigate);
160
+ void sync();
161
+
162
+ return {
163
+ sync,
164
+ dispose() {
165
+ document.removeEventListener('janux:navigate', onNavigate);
166
+ void chain.then(() => controller?.abort());
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,25 @@
1
+ /** @jsxImportSource .. */
2
+ import { describe, expect, it } from 'bun:test';
3
+
4
+ import { component } from './factories';
5
+ import { renderToString } from '../render/server';
6
+
7
+ const Island = component({
8
+ name: 'tag-island',
9
+ view: () => <p>hello</p>,
10
+ });
11
+
12
+ describe('component() as a JSX tag', () => {
13
+ it('typechecks as an element and server-renders as an island', async () => {
14
+ const page = (
15
+ <main>
16
+ <Island eager />
17
+ </main>
18
+ );
19
+ const result = await renderToString(page);
20
+
21
+ expect(result.html).toContain('data-jx="tag-island#');
22
+ expect(result.html).toContain('data-jx-eager');
23
+ expect(result.html).toContain('<p>hello</p>');
24
+ });
25
+ });
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ComponentDef,
3
+ ComponentTag,
3
4
  EffectDef,
4
5
  IntentDef,
5
6
  RefreshPolicy,
@@ -16,13 +17,13 @@ function assertName(name: unknown, kind: string): void {
16
17
  }
17
18
 
18
19
  /** Defines a bifacial component: view for humans, resource+tools for agents. */
19
- export function component(def: ComponentInput): ComponentDef {
20
+ export function component(def: ComponentInput): ComponentTag {
20
21
  assertName(def.name, 'component()');
21
22
  if (typeof def.view !== 'function') {
22
23
  throw new Error(`Janux: component "${def.name}" requires a view`);
23
24
  }
24
25
 
25
- return Object.freeze({ ...def, kind: 'component' as const });
26
+ return Object.freeze({ ...def, kind: 'component' as const }) as ComponentTag;
26
27
  }
27
28
 
28
29
  /** Defines a shared store: a bifacial component without a view. */
@@ -1,4 +1,5 @@
1
1
  import type { JxType } from '../schema';
2
+ import type { JanuxNode } from '../jsx-runtime';
2
3
 
3
4
  export type GuardValue = 'auto' | 'confirm' | 'forbidden';
4
5
  export type Guard = GuardValue | ((bag: { ctx: Ctx }) => GuardValue);
@@ -81,3 +82,11 @@ export interface ComponentDef {
81
82
  scope?: 'app' | 'route';
82
83
  persist?: 'local' | 'none';
83
84
  }
85
+
86
+ /**
87
+ * ComponentDef with a phantom call signature so TSX accepts `<MyIsland />` as
88
+ * an element. The signature never exists at runtime: `component()` returns a
89
+ * frozen plain object, and the renderer checks `typeof $t === 'function'`
90
+ * before `isComponentDef`, so defs always take the island path.
91
+ */
92
+ export type ComponentTag = ComponentDef & ((props?: Record<string, unknown>) => JanuxNode);
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  } from './define/factories';
11
11
  export type {
12
12
  ComponentDef,
13
+ ComponentTag,
13
14
  IntentDef,
14
15
  EffectDef,
15
16
  SourceDef,
@@ -40,5 +41,5 @@ export {
40
41
  export { signal, computed, effect as watch, batch, untrack } from './signals';
41
42
  export { createInstance, type JanuxInstance, type InstanceOptions } from './runtime/instance';
42
43
  export { createBus, type EventBus } from './runtime/bus';
43
- export { JanuxIntentError, type AuditEntry, type Proposal } from './runtime/intents';
44
+ export { JanuxIntentError, resolveGuard, type AuditEntry, type Proposal } from './runtime/intents';
44
45
  export { Fragment, jsx, jsxs, type JanuxNode } from './jsx-runtime';
@@ -29,6 +29,9 @@ export declare namespace JSX {
29
29
  interface IntrinsicElements {
30
30
  [element: string]: Record<string, unknown>;
31
31
  }
32
+ interface IntrinsicAttributes {
33
+ key?: string | number;
34
+ }
32
35
  interface ElementChildrenAttribute {
33
36
  children: unknown;
34
37
  }
@@ -75,6 +75,16 @@ describe('renderToString', () => {
75
75
  expect(result.html).toBe('<input value="x"/>');
76
76
  });
77
77
 
78
+ it('marks persist and eager islands with data attributes (SPA navigation)', async () => {
79
+ const persisted = await renderToString(jsx(cart as any, { persist: true }));
80
+ const eager = await renderToString(jsx(cart as any, { eager: true }));
81
+ const plain = await renderToString(jsx(cart as any, {}));
82
+
83
+ expect(persisted.html).toContain('data-jx="cart#default" data-jx-persist>');
84
+ expect(eager.html).toContain('data-jx="cart#default" data-jx-eager>');
85
+ expect(plain.html).toContain('data-jx="cart#default">');
86
+ });
87
+
78
88
  it('escapes attacker-controlled island keys (XSS regression)', async () => {
79
89
  const evil = 'x"><img src=x onerror=alert(1)>';
80
90
  const result = await renderToString(jsx(cart as any, { key: evil }));
@@ -69,8 +69,10 @@ async function renderIsland(def: ComponentDef, props: any, scope: RenderScope):
69
69
  await loadSources(instance);
70
70
  scope.registry.islands.push({ def, key, instance });
71
71
  const inner = await renderNode(def.view!(instance.bag), scope);
72
+ const persist = props.persist ? ' data-jx-persist' : '';
73
+ const eager = props.eager ? ' data-jx-eager' : '';
72
74
 
73
- return `<janux-island data-jx="${escapeHtml(`${def.name}#${key}`)}">${inner}</janux-island>`;
75
+ return `<janux-island data-jx="${escapeHtml(`${def.name}#${key}`)}"${persist}${eager}>${inner}</janux-island>`;
74
76
  }
75
77
 
76
78
  async function renderElement(node: JanuxNode, scope: RenderScope): Promise<string> {
@@ -1,5 +1,5 @@
1
1
  import { computed, effect as watch, untrack } from '../signals';
2
- import { allowMutations } from '../state/mutation-gate';
2
+ import { withGate, type MutationGate } from '../state/mutation-gate';
3
3
  import { parseDuration } from '../define/factories';
4
4
  import type { Cleanup, EffectDef, RunBag } from '../define/types';
5
5
  import type { PendingTracker } from './settled';
@@ -8,8 +8,8 @@ interface EffectRuntime {
8
8
  dispose: () => void;
9
9
  }
10
10
 
11
- function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker): Cleanup {
12
- const result = allowMutations(() => def.run(bag));
11
+ function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker, gate: MutationGate): Cleanup {
12
+ const result = withGate(gate, () => def.run(bag));
13
13
 
14
14
  if (result instanceof Promise) {
15
15
  tracker.track(result);
@@ -20,7 +20,7 @@ function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker): Cl
20
20
  return typeof result === 'function' ? result : undefined;
21
21
  }
22
22
 
23
- function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker): EffectRuntime {
23
+ function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker, gate: MutationGate): EffectRuntime {
24
24
  const debounceMs = def.debounce ? parseDuration(def.debounce) : 0;
25
25
  let cleanup: Cleanup;
26
26
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -29,7 +29,7 @@ function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker): EffectR
29
29
 
30
30
  const runNow = (): void => {
31
31
  cleanup?.();
32
- cleanup = untrack(() => runEffectBody(def, bag, tracker));
32
+ cleanup = untrack(() => runEffectBody(def, bag, tracker, gate));
33
33
  };
34
34
 
35
35
  const schedule = (): void => {
@@ -73,8 +73,9 @@ export function startEffects(
73
73
  defs: Record<string, EffectDef> | undefined,
74
74
  bag: RunBag,
75
75
  tracker: PendingTracker,
76
+ gate: MutationGate,
76
77
  ): () => void {
77
- const running = Object.values(defs ?? {}).map((def) => startOne(def, bag, tracker));
78
+ const running = Object.values(defs ?? {}).map((def) => startOne(def, bag, tracker, gate));
78
79
 
79
80
  return () => running.forEach((runtime) => runtime.dispose());
80
81
  }
@@ -48,6 +48,29 @@ describe('instance: state, derived, intents', () => {
48
48
  expect(cart.derived.count).toBe(3);
49
49
  });
50
50
 
51
+ it('async intents may mutate state after awaits (regression)', async () => {
52
+ const def = component({
53
+ name: 'async-cart',
54
+ state: schema({ items: list({ id: str(), qty: int() }), coupon: str().nullable() }),
55
+ intents: {
56
+ addThenCoupon: intent({
57
+ run: async ({ state }: any) => {
58
+ state.items.push({ id: 'a', qty: 1 });
59
+ await Promise.resolve();
60
+ state.coupon = 'SAVE10';
61
+ await new Promise((resolve) => setTimeout(resolve, 5));
62
+ state.items[0].qty = 2;
63
+ },
64
+ }),
65
+ },
66
+ view: noopView,
67
+ });
68
+ const cart = createInstance(def);
69
+
70
+ await cart.intents.addThenCoupon!({});
71
+ expect(cart.snapshot()).toEqual({ items: [{ id: 'a', qty: 2 }], coupon: 'SAVE10' });
72
+ });
73
+
51
74
  it('validates intent input against its schema', async () => {
52
75
  const cart = createInstance(cartDef());
53
76
 
@@ -1,7 +1,7 @@
1
1
  import { buildDefault, toJsonSchema, validate } from '../schema';
2
2
  import { computed, untrack, type ReadonlySig } from '../signals';
3
3
  import { createReactiveState } from '../state/reactive-state';
4
- import { allowMutations } from '../state/mutation-gate';
4
+ import { createGate, withGate } from '../state/mutation-gate';
5
5
  import type { ComponentDef, Ctx, Origin, RunBag, StoreHandle } from '../define/types';
6
6
  import { createBus, type EventBus } from './bus';
7
7
  import { createPendingTracker } from './settled';
@@ -79,7 +79,8 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
79
79
  const bus = options.bus ?? createBus();
80
80
  const tracker = createPendingTracker();
81
81
  const initial = options.initial ?? (def.state ? (buildDefault(def.state) as any) : {});
82
- const state = createReactiveState(initial as Record<string, unknown>);
82
+ const gate = createGate();
83
+ const state = createReactiveState(initial as Record<string, unknown>, gate);
83
84
  const sourcesRuntime = createSources(def.sources, ctx, bus, tracker, options.initialSources);
84
85
  const { readers: derived, dispose: disposeDerived } = derivedReaders(def, state.proxy);
85
86
  const emit = makeEmit(def, bus);
@@ -98,6 +99,7 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
98
99
  ctx,
99
100
  };
100
101
  const hooks = {
102
+ gate,
101
103
  onAudit: options.onAudit,
102
104
  onProposal: options.onProposal,
103
105
  trackPending: tracker.track,
@@ -113,7 +115,7 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
113
115
 
114
116
  let stopEffects: (() => void) | undefined;
115
117
  const busUnsubs = Object.entries(def.on ?? {}).map(([event, handler]) =>
116
- bus.on(event, (payload) => allowMutations(() => handler({ ...bag, event: payload }))),
118
+ bus.on(event, (payload) => withGate(gate, () => handler({ ...bag, event: payload }))),
117
119
  );
118
120
 
119
121
  return {
@@ -148,15 +150,15 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
148
150
  },
149
151
  async attach() {
150
152
  sourcesRuntime.start();
151
- stopEffects = startEffects(def.effects, bag, tracker);
152
- await allowMutations(() => def.lifecycle?.attach?.(bag));
153
+ stopEffects = startEffects(def.effects, bag, tracker, gate);
154
+ await withGate(gate, () => def.lifecycle?.attach?.(bag));
153
155
  },
154
156
  async dispose() {
155
157
  stopEffects?.();
156
158
  sourcesRuntime.dispose();
157
159
  disposeDerived();
158
160
  busUnsubs.forEach((unsub) => unsub());
159
- await allowMutations(() => def.lifecycle?.detach?.(bag));
161
+ await withGate(gate, () => def.lifecycle?.detach?.(bag));
160
162
  },
161
163
  handle(): StoreHandle {
162
164
  return { state: state.proxy, derived, intents: bindHumanIntents(intents) };
@@ -1,5 +1,5 @@
1
1
  import { validate } from '../schema';
2
- import { allowMutations } from '../state/mutation-gate';
2
+ import { withGate, type MutationGate } from '../state/mutation-gate';
3
3
  import type { ComponentDef, Ctx, GuardValue, IntentDef, Origin, RunBag } from '../define/types';
4
4
 
5
5
  export interface AuditEntry {
@@ -10,6 +10,8 @@ export interface AuditEntry {
10
10
  ok: boolean;
11
11
  error?: string;
12
12
  at: number;
13
+ /** Verified Web Bot Auth key id, when the caller is an authenticated agent. */
14
+ agent?: string;
13
15
  }
14
16
 
15
17
  export interface Proposal {
@@ -20,6 +22,7 @@ export interface Proposal {
20
22
  }
21
23
 
22
24
  export interface IntentHooks {
25
+ gate: MutationGate;
23
26
  onAudit?: (entry: AuditEntry) => void;
24
27
  onProposal?: (proposal: Proposal) => void;
25
28
  trackPending: <T>(work: Promise<T>) => Promise<T>;
@@ -71,8 +74,8 @@ function audit(hooks: IntentHooks, entry: Omit<AuditEntry, 'at'>): void {
71
74
  hooks.onAudit?.({ ...entry, at: Date.now() });
72
75
  }
73
76
 
74
- async function execute(def: IntentDef, bag: RunBag, input: unknown): Promise<unknown> {
75
- return allowMutations(() => def.run({ ...bag, input }));
77
+ async function execute(def: IntentDef, bag: RunBag, input: unknown, gate: MutationGate): Promise<unknown> {
78
+ return withGate(gate, () => def.run({ ...bag, input }));
76
79
  }
77
80
 
78
81
  function propose(tool: string, input: unknown, run: () => Promise<unknown>, hooks: IntentHooks) {
@@ -102,7 +105,7 @@ export async function invokeIntent(
102
105
  }
103
106
  checkInvocable(tool, def, bag);
104
107
  const parsed = parseInput(tool, def, input);
105
- const run = () => hooks.trackPending(execute(def, bag, parsed));
108
+ const run = () => hooks.trackPending(execute(def, bag, parsed, hooks.gate));
106
109
 
107
110
  if (origin === 'agent' && guard === 'confirm') {
108
111
  audit(hooks, { tool, origin, guard, input: parsed, ok: true });
@@ -1,17 +1,40 @@
1
- let mutationDepth = 0;
1
+ export interface MutationGate {
2
+ depth: number;
3
+ }
4
+
5
+ export function createGate(): MutationGate {
6
+ return { depth: 0 };
7
+ }
8
+
9
+ /**
10
+ * Runs `fn` with mutations enabled on ONE instance's gate. For async bodies
11
+ * the gate stays open until the promise settles, so `run()` may mutate after
12
+ * `await`s. Per-instance scoping means an in-flight async intent never opens
13
+ * the door for other components' state. A development guardrail, not an
14
+ * isolation boundary.
15
+ */
16
+ export function withGate<T>(gate: MutationGate, fn: () => T): T {
17
+ gate.depth += 1;
18
+ let result: T;
2
19
 
3
- /** Runs `fn` with state mutations enabled (used by intent/effect/event runners). */
4
- export function allowMutations<T>(fn: () => T): T {
5
- mutationDepth += 1;
6
20
  try {
7
- return fn();
8
- } finally {
9
- mutationDepth -= 1;
21
+ result = fn();
22
+ } catch (error) {
23
+ gate.depth -= 1;
24
+ throw error;
25
+ }
26
+ if (result instanceof Promise) {
27
+ return result.finally(() => {
28
+ gate.depth -= 1;
29
+ }) as T;
10
30
  }
31
+ gate.depth -= 1;
32
+
33
+ return result;
11
34
  }
12
35
 
13
- export function assertMutable(path: string): void {
14
- if (mutationDepth > 0) return;
36
+ export function assertMutable(gate: MutationGate, path: string): void {
37
+ if (gate.depth > 0) return;
15
38
 
16
39
  throw new Error(
17
40
  `Janux: illegal mutation of "${path}" outside an intent, effect or event handler. ` +
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, mock } from 'bun:test';
2
2
  import { effect } from '../signals';
3
- import { allowMutations } from './mutation-gate';
3
+ import { createGate, withGate } from './mutation-gate';
4
4
  import { createReactiveState } from './reactive-state';
5
5
 
6
6
  const initial = () => ({
@@ -10,14 +10,16 @@ const initial = () => ({
10
10
 
11
11
  describe('reactive state', () => {
12
12
  it('reads initial data through the proxy', () => {
13
- const state = createReactiveState(initial());
13
+ const gate = createGate();
14
+ const state = createReactiveState(initial(), gate);
14
15
 
15
16
  expect(state.proxy.items[0]!.qty).toBe(1);
16
17
  expect(state.proxy.coupon).toBe(null);
17
18
  });
18
19
 
19
20
  it('throws on mutation outside a run context', () => {
20
- const state = createReactiveState(initial());
21
+ const gate = createGate();
22
+ const state = createReactiveState(initial(), gate);
21
23
 
22
24
  expect(() => {
23
25
  state.proxy.coupon = 'SAVE10';
@@ -26,14 +28,15 @@ describe('reactive state', () => {
26
28
  });
27
29
 
28
30
  it('mutates inside allowMutations and notifies path readers', () => {
29
- const state = createReactiveState(initial());
31
+ const gate = createGate();
32
+ const state = createReactiveState(initial(), gate);
30
33
  const runs = mock(() => {});
31
34
 
32
35
  effect(() => {
33
36
  runs();
34
37
  state.proxy.items[0]!.qty;
35
38
  });
36
- allowMutations(() => {
39
+ withGate(gate, () => {
37
40
  state.proxy.items[0]!.qty = 5;
38
41
  });
39
42
  expect(runs).toHaveBeenCalledTimes(2);
@@ -41,16 +44,17 @@ describe('reactive state', () => {
41
44
  });
42
45
 
43
46
  it('notifies list readers on push and filter-reassign', () => {
44
- const state = createReactiveState(initial());
47
+ const gate = createGate();
48
+ const state = createReactiveState(initial(), gate);
45
49
  const lengths: number[] = [];
46
50
 
47
51
  effect(() => {
48
52
  lengths.push(state.proxy.items.length);
49
53
  });
50
- allowMutations(() => {
54
+ withGate(gate, () => {
51
55
  state.proxy.items.push({ id: 'b', qty: 2 });
52
56
  });
53
- allowMutations(() => {
57
+ withGate(gate, () => {
54
58
  state.proxy.items = state.proxy.items.filter((item) => item.id === 'b');
55
59
  });
56
60
  expect(lengths).toEqual([1, 2, 1]);
@@ -58,21 +62,23 @@ describe('reactive state', () => {
58
62
  });
59
63
 
60
64
  it('does not notify readers of untouched sibling paths', () => {
61
- const state = createReactiveState(initial());
65
+ const gate = createGate();
66
+ const state = createReactiveState(initial(), gate);
62
67
  const runs = mock(() => {});
63
68
 
64
69
  effect(() => {
65
70
  runs();
66
71
  state.proxy.coupon;
67
72
  });
68
- allowMutations(() => {
73
+ withGate(gate, () => {
69
74
  state.proxy.items[0]!.qty = 9;
70
75
  });
71
76
  expect(runs).toHaveBeenCalledTimes(1);
72
77
  });
73
78
 
74
79
  it('snapshot is plain data detached from the proxy', () => {
75
- const state = createReactiveState(initial());
80
+ const gate = createGate();
81
+ const state = createReactiveState(initial(), gate);
76
82
  const snap = state.snapshot();
77
83
 
78
84
  snap.items[0]!.qty = 99;
@@ -1,5 +1,5 @@
1
1
  import { batch, signal, untrack, type Sig } from '../signals';
2
- import { assertMutable } from './mutation-gate';
2
+ import { assertMutable, createGate, type MutationGate } from './mutation-gate';
3
3
 
4
4
  const MUTATING_ARRAY_METHODS = new Set([
5
5
  'push',
@@ -34,7 +34,10 @@ function plainObject(value: object): Record<string, unknown> {
34
34
  return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, plainify(v)]));
35
35
  }
36
36
 
37
- export function createReactiveState<T extends object>(initial: T): ReactiveState<T> {
37
+ export function createReactiveState<T extends object>(
38
+ initial: T,
39
+ gate: MutationGate = createGate(),
40
+ ): ReactiveState<T> {
38
41
  const versions = new Map<string, Sig<number>>();
39
42
  let data = structuredClone(initial);
40
43
 
@@ -78,7 +81,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
78
81
 
79
82
  const wrapArrayMethod = (target: unknown[], path: string, method: string) => {
80
83
  return (...args: unknown[]) => {
81
- assertMutable(path);
84
+ assertMutable(gate, path);
82
85
  const result = (target as any)[method](...args.map(plainify));
83
86
 
84
87
  touch(path);
@@ -112,7 +115,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
112
115
  if (typeof key === 'symbol') return Reflect.set(raw, key, value);
113
116
  const target = childPath(path, key);
114
117
 
115
- assertMutable(target);
118
+ assertMutable(gate, target);
116
119
  Reflect.set(raw, key, plainify(value));
117
120
  touch(target);
118
121
 
@@ -123,7 +126,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
123
126
  if (typeof key === 'symbol') return Reflect.deleteProperty(raw, key);
124
127
  const target = childPath(path, key);
125
128
 
126
- assertMutable(target);
129
+ assertMutable(gate, target);
127
130
  Reflect.deleteProperty(raw, key);
128
131
  touch(target);
129
132