@ryupold/vode 1.8.11 → 1.9.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.
@@ -1,16 +1,10 @@
1
- import { AnimatedPatch, DeepPartial, Patchable, PatchableState, RenderPatch } from "./vode";
1
+ import { DeepPartial, Patchable, PatchableState, RenderPatch } from "./vode";
2
2
 
3
3
  /**
4
4
  * State context for type-safe access and manipulation of nested state paths
5
5
  * while still be able to access the parent state.
6
6
  */
7
- export interface StateContext<S extends Patchable<S>, SubState> extends SubContext<SubState> {
8
- /**
9
- * parent state
10
- * @see PatchableState<S>
11
- */
12
- get state(): S;
13
- }
7
+ export interface StateContext<S extends Patchable<S>, SubState> extends SubContext<SubState> { }
14
8
 
15
9
  /**
16
10
  * State context for type-safe access and manipulation of nested sub-state values without knowledge of the parent state.
@@ -52,43 +46,56 @@ export type ProxySubContext<SubState> = SubContext<SubState> & {
52
46
  : SubContext<SubState[K]>
53
47
  };
54
48
 
49
+ type ProxyState<SubState> = SubState & {
50
+ [K in keyof SubState]-?: NonNullable<SubState[K]> extends object | null
51
+ ? ProxyState<NonNullable<SubState[K]>>
52
+ : SubState[K]
53
+ };
54
+
55
55
  /**
56
- * create a ProxyStateContext for type-safe dynamic access to nested state
57
- *
58
- * @example
56
+ * Creates a `ProxyStateContext` for type-safe access and manipulation of nested state.
57
+ *
58
+ * There are two ways to reach a subcontext:
59
+ *
60
+ * **1. Property chaining** — traverse the proxy directly via property access:
59
61
  * ```typescript
60
- * const state = createState({
61
- * user: {
62
- * profile: {
63
- * settings: { theme: 'dark', lang: 'en' }
64
- * }
65
- * });
66
- *
67
- * // Create a proxy context for the state
68
62
  * const ctx = context(state).user.profile.settings;
69
- *
70
- * // Access nested state dynamically
71
- * const settings = ctx.get(); // { theme: 'dark', lang: 'en' }
72
- *
73
- * // Update and trigger render
74
- * ctx.patch({ theme: 'light' });
75
- *
76
- * // Update without render (silent mutation)
77
- * ctx.put({ lang: 'de' });
78
63
  * ```
79
- *
80
- * @param state
81
- * @returns
64
+ *
65
+ * **2. Path producer function** — pass a callback that navigates
66
+ * the state tree; needed if your intermediate path contains 'get', 'put' or 'patch' properties that would conflict with the context API:
67
+ * ```typescript
68
+ * const ctx = context(state, s => s.user.profile.settings);
69
+ * ```
70
+ *
71
+ * Both forms return a `ProxyStateContext` that supports the same operations:
72
+ * ```typescript
73
+ * ctx.get(); // read current value
74
+ * ctx.patch({ theme: 'light' }); // update and trigger render
75
+ * ctx.put({ lang: 'de' }); // update without render (silent mutation)
76
+ * ```
77
+ *
78
+ * @param state - The root `PatchableState` to create a context for
79
+ * @param producePath - Optional path producer; receives a proxy of the state and should return the desired sub-node
80
+ * @returns A `ProxyStateContext` rooted at the given path, with further property-chain access available
82
81
  */
83
- export function context<S extends PatchableState>(state: S): ProxyStateContext<S, S> {
84
- return new ProxyStateContextImpl<S, S>(state, []) as unknown as ProxyStateContext<S, S>;
82
+
83
+ export function context<S extends PatchableState, SS = S>(state: S): ProxyStateContext<S, SS>;
84
+ export function context<S extends PatchableState, SS>(state: S, producePath: (ctx: ProxyState<S>) => ProxyState<SS>): ProxyStateContext<S, SS>;
85
+ export function context<S extends PatchableState, SS = S>(state: S, producePath?: (ctx: ProxyState<S>) => ProxyState<SS>): ProxyStateContext<S, SS> {
86
+ if (producePath) {
87
+ const proxy = producePath(proxyState<S>(state, [] as string[]));
88
+ const keys = (proxy as any)["___KeYs___"] as string[];
89
+ return new ProxyStateContextImpl<S, SS>(state, keys) as unknown as ProxyStateContext<S, SS>;
90
+ }
91
+ return new ProxyStateContextImpl<S, S>(state, []) as unknown as ProxyStateContext<S, SS>;
85
92
  }
86
93
 
87
94
  class ProxyStateContextImpl<S extends PatchableState, SubState>
88
95
  implements StateContext<S, SubState> {
89
96
 
90
97
  constructor(
91
- public readonly state: S,
98
+ private readonly state: S,
92
99
  private readonly keys: string[]
93
100
  ) {
94
101
  function putDeep(value: SubState | DeepPartial<SubState> | undefined | null, target: S | DeepPartial<S>) {
@@ -150,8 +157,6 @@ class ProxyStateContextImpl<S extends PatchableState, SubState>
150
157
 
151
158
  return new Proxy(this, {
152
159
  get: (target, prop, receiver) => {
153
- if (prop === 'state')
154
- return state;
155
160
 
156
161
  if (prop === 'get')
157
162
  return get;
@@ -162,10 +167,12 @@ class ProxyStateContextImpl<S extends PatchableState, SubState>
162
167
  if (prop === 'patch')
163
168
  return patch;
164
169
 
165
-
166
170
  // otherwise return a new ProxyStateContext for nested access
167
- const newKeys = [...target.keys, String(prop)];
171
+ const newKeys = [...keys, String(prop)];
168
172
  return new ProxyStateContextImpl<S, any>(target.state, newKeys);
173
+ },
174
+ set: (target: this, p: string | symbol, newValue: any, receiver: any) => {
175
+ throw new Error("ProxyStateContext is not meant to be directly mutated. Use put() or patch() methods on the StateContext instead");
169
176
  }
170
177
  });
171
178
  }
@@ -174,3 +181,23 @@ class ProxyStateContextImpl<S extends PatchableState, SubState>
174
181
  put(value: SubState | DeepPartial<SubState>): void { }
175
182
  patch(value: SubState | DeepPartial<SubState> | DeepPartial<SubState>[]): void { }
176
183
  }
184
+
185
+
186
+ function proxyState<S extends PatchableState>(
187
+ state: S,
188
+ keys: string[]
189
+ ) {
190
+ return new Proxy(state, {
191
+ get: (target, prop, receiver) => {
192
+ if (prop === "___KeYs___") {
193
+ return keys;
194
+ }
195
+
196
+ const newKeys = [...keys, String(prop)];
197
+ return proxyState<S>(state, newKeys);
198
+ },
199
+ set: (target: any, p: string | symbol, newValue: any, receiver: any) => {
200
+ throw new Error("ProxyState is not meant to be directly mutated");
201
+ }
202
+ });
203
+ }
package/src/vode.ts CHANGED
@@ -228,10 +228,10 @@ export function app<S extends PatchableState = PatchableState>(
228
228
 
229
229
  function renderDom(isAnimated: boolean) {
230
230
  const sw = performance.now();
231
- const vom = dom(_vode.state);
232
- _vode.vode = render<S>(_vode.state, container.parentElement as Element, 0, 0, _vode.vode, vom)!;
231
+ _vode.vode = render<S>(_vode.state, container.parentElement as Element, 0, 0, _vode.vode, dom(_vode.state))!;
233
232
 
234
- if ((<ContainerNode<S>>container).tagName.toUpperCase() !== (vom[0] as Tag).toUpperCase()) { //the tag name was changed during render -> update reference to vode-app-root
233
+ if ((<ContainerNode<S>>container).tagName.toLowerCase() !== (<Vode<S>>_vode.vode)[0].toLowerCase()) {
234
+ //the tag name was changed during render -> update reference to vode-app-root
235
235
  container = _vode.vode.node as Element;
236
236
  (<ContainerNode<S>>container)._vode = _vode
237
237
  }
@@ -299,6 +299,13 @@ export function app<S extends PatchableState = PatchableState>(
299
299
  hydrate<S>(container, true) as AttachedVode<S>,
300
300
  dom(<S>state)
301
301
  )!;
302
+
303
+ // if during initial render the tag of the root vode was changed (catch or different Tag)
304
+ if (container.tagName.toLowerCase() !== (<Vode<S>>_vode.vode)[0].toLowerCase()) {
305
+ container = _vode.vode.node as Element;
306
+ (container as ContainerNode<S>)._vode = _vode;
307
+ }
308
+
302
309
  const continueRendering = _vode.stats.syncRenderPatchCount !== patchCountBefore;
303
310
  _vode.isRendering = 0;
304
311
  _vode.stats.syncRenderCount++;
package/test/helper.ts CHANGED
@@ -255,7 +255,7 @@ export class Expectation {
255
255
  } else {
256
256
  attributeValue = (e as HTMLElement).getAttribute(k);
257
257
  }
258
- if (!attributeValue) {
258
+ if (attributeValue === null) {
259
259
  throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nwith attribute [${k}="${val}"]\n\nbut it was not found${failSuffix}`);
260
260
  }
261
261
  if (attributeValue !== val) {
package/test/tests-app.ts CHANGED
@@ -120,8 +120,7 @@ export default {
120
120
 
121
121
  await state.patch([{ a: 10 }, { b: 20 }]);
122
122
 
123
- await expect(state.a).toEqual(10);
124
- await expect(state.b).toEqual(20);
123
+ await expect(state).toEqual({ a: 10, b: 20 });
125
124
  },
126
125
 
127
126
  "app(): multiple sequential patches both apply": async () => {
@@ -339,4 +338,45 @@ export default {
339
338
  const el = (container as any)._vode.vode.node;
340
339
  expect(el.onclick).toBeA("function");
341
340
  },
341
+
342
+ "app(): class as array renders correctly": async () => {
343
+ const root = document.createElement("div");
344
+ const container = document.createElement("div");
345
+ root.appendChild(container);
346
+
347
+ app(container, {}, () =>
348
+ [DIV, { class: ["foo", "bar", "baz"] }, "text"]
349
+ );
350
+
351
+ await expect(container).toMatch([DIV, { class: "foo bar baz" }, "text"]);
352
+ },
353
+
354
+ "app(): class as number becomes empty string": async () => {
355
+ const root = document.createElement("div");
356
+ const container = document.createElement("div");
357
+ root.appendChild(container);
358
+
359
+ app(container, {}, () =>
360
+ [DIV, { class: 123 as any }, "text"]
361
+ );
362
+
363
+ await expect(container).toMatch([DIV, { class: "" }, "text"]);
364
+ },
365
+
366
+ "app(): style object to string transition": async () => {
367
+ const root = document.createElement("div");
368
+ const container = document.createElement("div");
369
+ root.appendChild(container);
370
+ const state: any = { useObject: true };
371
+
372
+ app(container, state, (s: any) =>
373
+ [DIV, { style: s.useObject ? { color: "red" } : "color: blue" }, "text"]
374
+ );
375
+
376
+ await expect(container).toMatch([DIV, "text"]);
377
+
378
+ state.patch({ useObject: false });
379
+
380
+ await expect(container).toMatch([DIV, "text"]);
381
+ },
342
382
  };
@@ -1,5 +1,5 @@
1
1
  import { expect } from "./helper";
2
- import { app, createState, DIV, ARTICLE, SECTION, P } from "../index";
2
+ import { app, createState, DIV, ARTICLE, SECTION, P, MAIN } from "../index";
3
3
 
4
4
  function setup() {
5
5
  const root = document.createElement("div");
@@ -157,4 +157,64 @@ export default {
157
157
  ]
158
158
  );
159
159
  },
160
+
161
+ "catch: bubbles up to the root component if deeply nested vodes dont catch it earlier": async () => {
162
+ const root = document.createElement("div");
163
+ const container = document.createElement("div");
164
+ root.appendChild(container);
165
+
166
+ app(container, {}, () =>
167
+ [DIV,
168
+ {
169
+ catch: (s: unknown, err: Error) => [DIV, `caught: ${err.message}`]
170
+ },
171
+
172
+ [MAIN,
173
+ [SECTION,
174
+ [ARTICLE, {
175
+ onMount: () => {
176
+ throw new Error("boom");
177
+ }
178
+ }],
179
+ ],
180
+ ]
181
+ ]
182
+ );
183
+
184
+ await expect(container).toMatch(
185
+ [DIV, "caught: boom"],
186
+ );
187
+ },
188
+
189
+ "catch: if catching in root vode with different Tag -> container will be replaced": async () => {
190
+ const root = document.createElement("div");
191
+ const container = document.createElement("div");
192
+ root.appendChild(container);
193
+
194
+ await expect(root.firstChild === container).toEqual(true);
195
+
196
+ app(container, {}, () =>
197
+ [DIV,
198
+ {
199
+ catch: (s: unknown, err: Error) => [P, `caught: ${err.message}`]
200
+ },
201
+
202
+ [MAIN,
203
+ [SECTION,
204
+ [ARTICLE, {
205
+ onMount: () => {
206
+ throw new Error("boom");
207
+ }
208
+ }],
209
+ ],
210
+ ]
211
+ ]
212
+ );
213
+
214
+ await expect(root.firstChild === container).toEqual(false);
215
+
216
+ await expect(root.firstChild).toMatch(
217
+ [P, "caught: boom"],
218
+ );
219
+ },
160
220
  };
@@ -91,7 +91,7 @@ export default {
91
91
  [INPUT],
92
92
  [BUTTON, "Add"],
93
93
  [NAV,
94
- [BUTTON, "All"],
94
+ [BUTTON, { class: 'active' }, "All"],
95
95
  [BUTTON, "Active"],
96
96
  [BUTTON, "Done"],
97
97
  ],
@@ -115,7 +115,7 @@ export default {
115
115
  [BUTTON, "Add"],
116
116
  [NAV,
117
117
  [BUTTON, "All"],
118
- [BUTTON, "Active"],
118
+ [BUTTON, { class: 'active' }, "Active"],
119
119
  [BUTTON, "Done"],
120
120
  ],
121
121
  [UL,
@@ -135,7 +135,7 @@ export default {
135
135
  [NAV,
136
136
  [BUTTON, "All"],
137
137
  [BUTTON, "Active"],
138
- [BUTTON, "Done"],
138
+ [BUTTON, { class: 'active' }, "Done"],
139
139
  ],
140
140
  [UL,
141
141
  [LI, "[X] Walk dog"],
@@ -43,12 +43,14 @@ export default {
43
43
  await expect(mergeClass({ foo: true, bar: true }, { bar: false, baz: true })).toEqual({ foo: true, bar: false, baz: true });
44
44
  },
45
45
 
46
- "mergeClass(): object and array": async () => {
47
- await expect(mergeClass({ foo: true }, ["bar", "baz"])).toEqual({ foo: true, 0: "bar", 1: "baz" });
46
+ "mergeClass(): object and array (array items become class names with true)": async () => {
47
+ await expect(mergeClass({ foo: true }, ["bar", "baz"])).toEqual({ foo: true, bar: true, baz: true });
48
+ await expect(mergeClass({ active: true }, ["btn", "primary"])).toEqual({ active: true, btn: true, primary: true });
48
49
  },
49
50
 
50
- "mergeClass(): array and object": async () => {
51
- await expect(mergeClass(["foo", "bar"], { baz: true, qux: false })).toEqual({ 0: "foo", 1: "bar", baz: true, qux: false });
51
+ "mergeClass(): array and object (object keys become class names)": async () => {
52
+ await expect(mergeClass(["foo", "bar"], { baz: true, qux: false })).toEqual({ foo: true, bar: true, baz: true, qux: false });
53
+ await expect(mergeClass(["a", "b"], { c: true, d: false })).toEqual({ a: true, b: true, c: true, d: false });
52
54
  },
53
55
 
54
56
  "mergeClass(): falsy entries are skipped": async () => {
@@ -59,5 +61,10 @@ export default {
59
61
  "mergeClass(): multiple args (3+)": async () => {
60
62
  await expect(mergeClass("a", "b", "c")).toEqual("a b c");
61
63
  await expect(mergeClass("x", null, ["y", "z"], "w")).toEqual("y z x w");
64
+ },
65
+
66
+ "mergeClass(): incompatible types throw": async () => {
67
+ await expect(() => (mergeClass as any)(123 as any, "foo")).toFail();
68
+ await expect(() => (mergeClass as any)("foo", 456 as any)).toFail();
62
69
  }
63
70
  };
@@ -11,6 +11,11 @@ export default {
11
11
  await expect(mergeProps(p) === p).toEqual(true);
12
12
  },
13
13
 
14
+ "mergeProps(): single falsy arg returns undefined": async () => {
15
+ await expect(mergeProps(null)).toEqual(undefined);
16
+ await expect(mergeProps(undefined)).toEqual(undefined);
17
+ },
18
+
14
19
  "mergeProps(): two plain objects merged": async () => {
15
20
  await expect(mergeProps({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
16
21
  },
@@ -11,8 +11,8 @@ function setup() {
11
11
  export default {
12
12
  "patch(): generator function yields multiple state updates": async () => {
13
13
  const container = setup();
14
- const state: any = createState({ count: 0 });
15
- app(container, state, (s: any) => [DIV, String(s.count)]);
14
+ const state = createState({ count: 0 });
15
+ app<typeof state>(container, state, (s) => [DIV, String(s.count)]);
16
16
 
17
17
  await expect(state.count).toEqual(0);
18
18
 
@@ -30,8 +30,8 @@ export default {
30
30
 
31
31
  "patch(): async generator yields over time": async () => {
32
32
  const container = setup();
33
- const state: any = createState({ phase: "start", value: 0 });
34
- app(container, state, (s: any) => [DIV, s.phase, String(s.value)]);
33
+ const state = createState({ phase: "start", value: 0 });
34
+ app<typeof state>(container, state, (s) => [DIV, s.phase, String(s.value)]);
35
35
 
36
36
  await expect(state.phase).toEqual("start");
37
37
 
@@ -53,38 +53,37 @@ export default {
53
53
 
54
54
  "patch(): Promise resolves and applies patch": async () => {
55
55
  const container = setup();
56
- const state: any = createState({ msg: "before" });
57
- app(container, state, (s: any) => [DIV, s.msg]);
58
-
59
- state.patch(Promise.resolve({ msg: "after" }));
56
+ const state = createState({ msg: "before" });
57
+ app<typeof state>(container, state, (s) => [DIV, s.msg]);
60
58
 
61
- await delay(10);
59
+ await state.patch(Promise.resolve({ msg: "after" }));
62
60
 
63
- await expect(state.msg).toEqual("after");
61
+ await expect(state).toEqual({ msg: "after" });
64
62
  await expect(container).toMatch([DIV, "after"]);
65
63
  },
66
64
 
67
65
  "patch(): array with empty patches applies nothing": async () => {
68
66
  const container = setup();
69
- const state: any = createState({ x: 1, y: 2 });
70
- app(container, state, (s: any) => [DIV]);
67
+ const state = createState({ x: 1, y: 2 });
68
+ app(container, state, (s) => [DIV]);
69
+
70
+ await state.patch([{}, {}]);
71
71
 
72
- state.patch([{}, {}]);
73
- await expect(state.x).toEqual(1);
74
- await expect(state.y).toEqual(2);
72
+ await delay(10);
73
+ await expect(state).toEqual({ x: 1, y: 2 });
75
74
  },
76
75
 
77
76
  "patch(): array with null/undefined items skips them": async () => {
78
77
  const container = setup();
79
- const state: any = createState({ x: 0, y: 0 });
80
- app(container, state, (s: any) => [DIV, String(s.x), String(s.y)]);
78
+ const state = createState({ x: 0, y: 0 });
79
+ app<typeof state>(container, state, (s) => [DIV, String(s.x), String(s.y)]);
81
80
 
82
81
  state.patch([null, { x: 10 }, undefined, { y: 20 }]);
83
82
 
84
- await delay(10);
85
-
86
- await expect(state.x).toEqual(10);
87
- await expect(state.y).toEqual(20);
83
+ await expect(() => expect(state.x).toEqual(10))
84
+ .toSucceedAsync();
85
+ await expect(() => expect(state.y).toEqual(20))
86
+ .toSucceedAsync();
88
87
  },
89
88
 
90
89
  "patch(): returns Promise for generator functions, can be awaited": async () => {