mvc-kit 2.13.1 → 2.14.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.
Files changed (61) hide show
  1. package/BEST_PRACTICES.md +53 -4
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +2 -2
  3. package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +2 -2
  4. package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +47 -0
  5. package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +11 -0
  6. package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +103 -22
  7. package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +3 -2
  8. package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +7 -6
  9. package/agent-config/copilot/copilot-instructions.md +34 -0
  10. package/agent-config/cursor/cursorrules +34 -0
  11. package/agent-config/lib/install-claude.mjs +39 -116
  12. package/dist/react/components/DataTable.cjs +5 -9
  13. package/dist/react/components/DataTable.cjs.map +1 -1
  14. package/dist/react/components/DataTable.d.ts.map +1 -1
  15. package/dist/react/components/DataTable.js +5 -9
  16. package/dist/react/components/DataTable.js.map +1 -1
  17. package/dist/react/use-instance.cjs +6 -3
  18. package/dist/react/use-instance.cjs.map +1 -1
  19. package/dist/react/use-instance.d.ts.map +1 -1
  20. package/dist/react/use-instance.js +6 -3
  21. package/dist/react/use-instance.js.map +1 -1
  22. package/dist/react/use-local.cjs +1 -0
  23. package/dist/react/use-local.cjs.map +1 -1
  24. package/dist/react/use-local.js +1 -0
  25. package/dist/react/use-local.js.map +1 -1
  26. package/dist/react/use-model.cjs +34 -8
  27. package/dist/react/use-model.cjs.map +1 -1
  28. package/dist/react/use-model.d.ts.map +1 -1
  29. package/dist/react/use-model.js +34 -8
  30. package/dist/react/use-model.js.map +1 -1
  31. package/dist/react/use-subscribe-only.cjs +3 -2
  32. package/dist/react/use-subscribe-only.cjs.map +1 -1
  33. package/dist/react/use-subscribe-only.d.ts.map +1 -1
  34. package/dist/react/use-subscribe-only.js +3 -2
  35. package/dist/react/use-subscribe-only.js.map +1 -1
  36. package/examples/react/AuthExample/src/components/AdminPage.tsx +3 -1
  37. package/examples/react/AuthExample/src/components/DashboardPage.tsx +4 -2
  38. package/examples/react/AuthExample/src/components/ProfilePage.tsx +2 -1
  39. package/package.json +1 -1
  40. package/src/Model.md +55 -6
  41. package/src/Service.md +4 -1
  42. package/src/react/components/DataTable.tsx +9 -13
  43. package/src/react/use-instance.ts +14 -3
  44. package/src/react/use-local.ts +2 -1
  45. package/src/react/use-model.md +51 -4
  46. package/src/react/use-model.test.tsx +86 -0
  47. package/src/react/use-model.ts +44 -15
  48. package/src/react/use-subscribe-only.ts +3 -2
  49. /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
  50. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
  51. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
  52. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
  53. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
  54. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
  55. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
  56. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
  57. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
  58. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
  59. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
  60. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
  61. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
@@ -1,19 +1,38 @@
1
1
  const require_guards = require("./guards.cjs");
2
2
  let react = require("react");
3
3
  //#region src/react/use-model.ts
4
+ var __DEV__ = typeof __MVC_KIT_DEV__ !== "undefined" && __MVC_KIT_DEV__;
5
+ function assertModelExists(model, hookName) {
6
+ if (model) return;
7
+ throw new Error(`${hookName}: received an undefined/null model. This usually means the component rendered before the ViewModel finished creating the model. Either (a) create the model in the ViewModel constructor when the initial data is available (so it exists from the first render), or (b) if the model is created in onInit() after an async fetch, guard the component render with \`if (!vm.model) return <Spinner />\` before calling this hook. Do not use \`public model!: FormModel\` — the \`!\` lies to TypeScript about runtime state.`);
8
+ }
4
9
  /**
5
10
  * Bind to a component-scoped Model with validation and dirty state exposed.
6
11
  */
7
12
  function useModel(factory) {
8
13
  const modelRef = (0, react.useRef)(null);
9
14
  const mountedRef = (0, react.useRef)(false);
10
- if (!modelRef.current || modelRef.current.disposed) modelRef.current = factory();
11
- const modelSubscribe = (0, react.useCallback)((onStoreChange) => modelRef.current.subscribe(onStoreChange), []);
12
- const modelSnapshot = (0, react.useCallback)(() => modelRef.current.state, []);
15
+ let model = modelRef.current;
16
+ if (!model || model.disposed) {
17
+ model = factory();
18
+ if (__DEV__) assertModelExists(model, "useModel");
19
+ modelRef.current = model;
20
+ }
21
+ const modelSubscribe = (0, react.useCallback)((onStoreChange) => {
22
+ const m = modelRef.current;
23
+ return m ? m.subscribe(onStoreChange) : () => {};
24
+ }, []);
25
+ const modelSnapshot = (0, react.useCallback)(() => {
26
+ const m = modelRef.current;
27
+ if (!m) throw new Error("useModel: model accessed before creation");
28
+ return m.state;
29
+ }, []);
13
30
  (0, react.useSyncExternalStore)(modelSubscribe, modelSnapshot, modelSnapshot);
14
31
  (0, react.useEffect)(() => {
32
+ const m = modelRef.current;
33
+ if (!m) return;
15
34
  mountedRef.current = true;
16
- if (require_guards.isInitializable(modelRef.current)) modelRef.current.init();
35
+ if (require_guards.isInitializable(m)) m.init();
17
36
  return () => {
18
37
  mountedRef.current = false;
19
38
  setTimeout(() => {
@@ -21,7 +40,6 @@ function useModel(factory) {
21
40
  }, 0);
22
41
  };
23
42
  }, []);
24
- const model = modelRef.current;
25
43
  return {
26
44
  state: model.state,
27
45
  errors: model.errors,
@@ -41,10 +59,17 @@ function useModel(factory) {
41
59
  function useModelRef(factory) {
42
60
  const modelRef = (0, react.useRef)(null);
43
61
  const mountedRef = (0, react.useRef)(false);
44
- if (!modelRef.current || modelRef.current.disposed) modelRef.current = factory();
62
+ let model = modelRef.current;
63
+ if (!model || model.disposed) {
64
+ model = factory();
65
+ if (__DEV__) assertModelExists(model, "useModelRef");
66
+ modelRef.current = model;
67
+ }
45
68
  (0, react.useEffect)(() => {
69
+ const m = modelRef.current;
70
+ if (!m) return;
46
71
  mountedRef.current = true;
47
- if (require_guards.isInitializable(modelRef.current)) modelRef.current.init();
72
+ if (require_guards.isInitializable(m)) m.init();
48
73
  return () => {
49
74
  mountedRef.current = false;
50
75
  setTimeout(() => {
@@ -52,12 +77,13 @@ function useModelRef(factory) {
52
77
  }, 0);
53
78
  };
54
79
  }, []);
55
- return modelRef.current;
80
+ return model;
56
81
  }
57
82
  /**
58
83
  * Bind to a single Model field with surgical re-renders.
59
84
  */
60
85
  function useField(model, field) {
86
+ if (__DEV__) assertModelExists(model, "useField");
61
87
  const getSnapshot = (0, react.useCallback)(() => {
62
88
  return {
63
89
  value: model.state[field],
@@ -1 +1 @@
1
- {"version":3,"file":"use-model.cjs","names":[],"sources":["../../src/react/use-model.ts"],"sourcesContent":["import { useRef, useEffect, useSyncExternalStore, useCallback } from 'react';\nimport type { Model } from '../Model';\nimport type { ValidationErrors } from '../types';\nimport type { StateOf } from './types';\nimport { isInitializable } from './guards';\n\n/** Return type of `useModel`, providing state, validation, and model access. */\nexport interface ModelHandle<S extends object, M extends Model<S>> {\n state: S;\n errors: ValidationErrors<S>;\n valid: boolean;\n dirty: boolean;\n model: M;\n}\n\n/**\n * Bind to a component-scoped Model with validation and dirty state exposed.\n */\nexport function useModel<M extends Model<any>>(\n factory: () => M\n): ModelHandle<StateOf<M>, M> {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n if (!modelRef.current || modelRef.current.disposed) {\n modelRef.current = factory();\n }\n\n const modelSubscribe = useCallback(\n (onStoreChange: () => void) => modelRef.current!.subscribe(onStoreChange),\n []\n );\n const modelSnapshot = useCallback(\n () => modelRef.current!.state,\n []\n );\n useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);\n\n useEffect(() => {\n mountedRef.current = true;\n if (isInitializable(modelRef.current)) {\n modelRef.current.init();\n }\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n const model = modelRef.current;\n\n return {\n state: model.state,\n errors: model.errors,\n valid: model.valid,\n dirty: model.dirty,\n model,\n };\n}\n\n/**\n * Create a component-scoped Model with lifecycle management (init + dispose)\n * but NO state subscription. The parent component never re-renders from\n * model state changes.\n *\n * Designed for the per-field isolation pattern: parent creates the model\n * via `useModelRef`, children subscribe to individual fields via `useField`.\n */\nexport function useModelRef<M extends Model<any>>(factory: () => M): M {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n if (!modelRef.current || modelRef.current.disposed) {\n modelRef.current = factory();\n }\n\n useEffect(() => {\n mountedRef.current = true;\n if (isInitializable(modelRef.current)) {\n modelRef.current.init();\n }\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return modelRef.current;\n}\n\n/** Return type of `useField`, providing a single field's value, error, and setter. */\nexport interface FieldHandle<V> {\n value: V;\n error: string | undefined;\n set: (value: V) => void;\n}\n\n/**\n * Bind to a single Model field with surgical re-renders.\n */\nexport function useField<S extends object, K extends keyof S>(\n model: Model<S>,\n field: K\n): FieldHandle<S[K]> {\n // Track the field value and error for comparison\n const getSnapshot = useCallback(() => {\n return {\n value: model.state[field],\n error: model.errors[field],\n };\n }, [model, field]);\n\n // Use object comparison for subscription\n const cachedRef = useRef(getSnapshot());\n\n const subscribe = useCallback(\n (onStoreChange: () => void) => {\n return model.subscribe(() => {\n const next = getSnapshot();\n const current = cachedRef.current;\n\n // Only trigger re-render if field value or error changed\n if (next.value !== current.value || next.error !== current.error) {\n cachedRef.current = next;\n onStoreChange();\n }\n });\n },\n [model, getSnapshot]\n );\n\n const snapshot = useSyncExternalStore(\n subscribe,\n () => cachedRef.current,\n () => cachedRef.current\n );\n\n const set = useCallback(\n (value: S[K]) => {\n // Access the protected set method through type assertion\n // The Model subclass should expose a setter method\n const partial: Partial<S> = { [field]: value } as unknown as Partial<S>;\n (model as unknown as { set: (partial: Partial<S>) => void }).set(partial);\n },\n [model, field]\n );\n\n return {\n value: snapshot.value,\n error: snapshot.error,\n set,\n };\n}\n"],"mappings":";;;;;;AAkBA,SAAgB,SACd,SAC4B;CAC5B,MAAM,YAAA,GAAA,MAAA,QAA4B,KAAK;CACvC,MAAM,cAAA,GAAA,MAAA,QAAoB,MAAM;AAEhC,KAAI,CAAC,SAAS,WAAW,SAAS,QAAQ,SACxC,UAAS,UAAU,SAAS;CAG9B,MAAM,kBAAA,GAAA,MAAA,cACH,kBAA8B,SAAS,QAAS,UAAU,cAAc,EACzE,EAAE,CACH;CACD,MAAM,iBAAA,GAAA,MAAA,mBACE,SAAS,QAAS,OACxB,EAAE,CACH;AACD,EAAA,GAAA,MAAA,sBAAqB,gBAAgB,eAAe,cAAc;AAElE,EAAA,GAAA,MAAA,iBAAgB;AACd,aAAW,UAAU;AACrB,MAAI,eAAA,gBAAgB,SAAS,QAAQ,CACnC,UAAS,QAAQ,MAAM;AAEzB,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;CAEN,MAAM,QAAQ,SAAS;AAEvB,QAAO;EACL,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,OAAO,MAAM;EACb,OAAO,MAAM;EACb;EACD;;;;;;;;;;AAWH,SAAgB,YAAkC,SAAqB;CACrE,MAAM,YAAA,GAAA,MAAA,QAA4B,KAAK;CACvC,MAAM,cAAA,GAAA,MAAA,QAAoB,MAAM;AAEhC,KAAI,CAAC,SAAS,WAAW,SAAS,QAAQ,SACxC,UAAS,UAAU,SAAS;AAG9B,EAAA,GAAA,MAAA,iBAAgB;AACd,aAAW,UAAU;AACrB,MAAI,eAAA,gBAAgB,SAAS,QAAQ,CACnC,UAAS,QAAQ,MAAM;AAEzB,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO,SAAS;;;;;AAalB,SAAgB,SACd,OACA,OACmB;CAEnB,MAAM,eAAA,GAAA,MAAA,mBAAgC;AACpC,SAAO;GACL,OAAO,MAAM,MAAM;GACnB,OAAO,MAAM,OAAO;GACrB;IACA,CAAC,OAAO,MAAM,CAAC;CAGlB,MAAM,aAAA,GAAA,MAAA,QAAmB,aAAa,CAAC;CAkBvC,MAAM,YAAA,GAAA,MAAA,uBAAA,GAAA,MAAA,cAfH,kBAA8B;AAC7B,SAAO,MAAM,gBAAgB;GAC3B,MAAM,OAAO,aAAa;GAC1B,MAAM,UAAU,UAAU;AAG1B,OAAI,KAAK,UAAU,QAAQ,SAAS,KAAK,UAAU,QAAQ,OAAO;AAChE,cAAU,UAAU;AACpB,mBAAe;;IAEjB;IAEJ,CAAC,OAAO,YAAY,CACrB,QAIO,UAAU,eACV,UAAU,QACjB;CAED,MAAM,OAAA,GAAA,MAAA,cACH,UAAgB;EAGf,MAAM,UAAsB,GAAG,QAAQ,OAAO;AAC7C,QAA4D,IAAI,QAAQ;IAE3E,CAAC,OAAO,MAAM,CACf;AAED,QAAO;EACL,OAAO,SAAS;EAChB,OAAO,SAAS;EAChB;EACD"}
1
+ {"version":3,"file":"use-model.cjs","names":[],"sources":["../../src/react/use-model.ts"],"sourcesContent":["import { useRef, useEffect, useSyncExternalStore, useCallback } from 'react';\nimport type { Model } from '../Model';\nimport type { ValidationErrors } from '../types';\nimport type { StateOf } from './types';\nimport { isInitializable } from './guards';\n\nconst __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;\n\nfunction assertModelExists(\n model: unknown,\n hookName: 'useModel' | 'useModelRef' | 'useField',\n): asserts model {\n if (model) return;\n throw new Error(\n `${hookName}: received an undefined/null model. ` +\n 'This usually means the component rendered before the ViewModel finished creating the model. ' +\n 'Either (a) create the model in the ViewModel constructor when the initial data is available ' +\n '(so it exists from the first render), or (b) if the model is created in onInit() after an async ' +\n 'fetch, guard the component render with `if (!vm.model) return <Spinner />` before calling this hook. ' +\n 'Do not use `public model!: FormModel` — the `!` lies to TypeScript about runtime state.',\n );\n}\n\n/** Return type of `useModel`, providing state, validation, and model access. */\nexport interface ModelHandle<S extends object, M extends Model<S>> {\n state: S;\n errors: ValidationErrors<S>;\n valid: boolean;\n dirty: boolean;\n model: M;\n}\n\n/**\n * Bind to a component-scoped Model with validation and dirty state exposed.\n */\nexport function useModel<M extends Model<any>>(\n factory: () => M\n): ModelHandle<StateOf<M>, M> {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n let model = modelRef.current;\n if (!model || model.disposed) {\n model = factory();\n if (__DEV__) assertModelExists(model, 'useModel');\n modelRef.current = model;\n }\n\n const modelSubscribe = useCallback(\n (onStoreChange: () => void) => {\n const m = modelRef.current;\n return m ? m.subscribe(onStoreChange) : () => {};\n },\n []\n );\n const modelSnapshot = useCallback(\n () => {\n const m = modelRef.current;\n if (!m) throw new Error('useModel: model accessed before creation');\n return m.state;\n },\n []\n );\n useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);\n\n useEffect(() => {\n const m = modelRef.current;\n if (!m) return;\n mountedRef.current = true;\n if (isInitializable(m)) m.init();\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return {\n state: model.state,\n errors: model.errors,\n valid: model.valid,\n dirty: model.dirty,\n model,\n };\n}\n\n/**\n * Create a component-scoped Model with lifecycle management (init + dispose)\n * but NO state subscription. The parent component never re-renders from\n * model state changes.\n *\n * Designed for the per-field isolation pattern: parent creates the model\n * via `useModelRef`, children subscribe to individual fields via `useField`.\n */\nexport function useModelRef<M extends Model<any>>(factory: () => M): M {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n let model = modelRef.current;\n if (!model || model.disposed) {\n model = factory();\n if (__DEV__) assertModelExists(model, 'useModelRef');\n modelRef.current = model;\n }\n\n useEffect(() => {\n const m = modelRef.current;\n if (!m) return;\n mountedRef.current = true;\n if (isInitializable(m)) m.init();\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return model;\n}\n\n/** Return type of `useField`, providing a single field's value, error, and setter. */\nexport interface FieldHandle<V> {\n value: V;\n error: string | undefined;\n set: (value: V) => void;\n}\n\n/**\n * Bind to a single Model field with surgical re-renders.\n */\nexport function useField<S extends object, K extends keyof S>(\n model: Model<S>,\n field: K\n): FieldHandle<S[K]> {\n if (__DEV__) assertModelExists(model, 'useField');\n // Track the field value and error for comparison\n const getSnapshot = useCallback(() => {\n return {\n value: model.state[field],\n error: model.errors[field],\n };\n }, [model, field]);\n\n // Use object comparison for subscription\n const cachedRef = useRef(getSnapshot());\n\n const subscribe = useCallback(\n (onStoreChange: () => void) => {\n return model.subscribe(() => {\n const next = getSnapshot();\n const current = cachedRef.current;\n\n // Only trigger re-render if field value or error changed\n if (next.value !== current.value || next.error !== current.error) {\n cachedRef.current = next;\n onStoreChange();\n }\n });\n },\n [model, getSnapshot]\n );\n\n const snapshot = useSyncExternalStore(\n subscribe,\n () => cachedRef.current,\n () => cachedRef.current\n );\n\n const set = useCallback(\n (value: S[K]) => {\n // Access the protected set method through type assertion\n // The Model subclass should expose a setter method\n const partial: Partial<S> = { [field]: value } as unknown as Partial<S>;\n (model as unknown as { set: (partial: Partial<S>) => void }).set(partial);\n },\n [model, field]\n );\n\n return {\n value: snapshot.value,\n error: snapshot.error,\n set,\n };\n}\n"],"mappings":";;;AAMA,IAAM,UAAU,OAAO,oBAAoB,eAAe;AAE1D,SAAS,kBACP,OACA,UACe;AACf,KAAI,MAAO;AACX,OAAM,IAAI,MACR,GAAG,SAAS,ggBAMb;;;;;AAeH,SAAgB,SACd,SAC4B;CAC5B,MAAM,YAAA,GAAA,MAAA,QAA4B,KAAK;CACvC,MAAM,cAAA,GAAA,MAAA,QAAoB,MAAM;CAEhC,IAAI,QAAQ,SAAS;AACrB,KAAI,CAAC,SAAS,MAAM,UAAU;AAC5B,UAAQ,SAAS;AACjB,MAAI,QAAS,mBAAkB,OAAO,WAAW;AACjD,WAAS,UAAU;;CAGrB,MAAM,kBAAA,GAAA,MAAA,cACH,kBAA8B;EAC7B,MAAM,IAAI,SAAS;AACnB,SAAO,IAAI,EAAE,UAAU,cAAc,SAAS;IAEhD,EAAE,CACH;CACD,MAAM,iBAAA,GAAA,MAAA,mBACE;EACJ,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG,OAAM,IAAI,MAAM,2CAA2C;AACnE,SAAO,EAAE;IAEX,EAAE,CACH;AACD,EAAA,GAAA,MAAA,sBAAqB,gBAAgB,eAAe,cAAc;AAElE,EAAA,GAAA,MAAA,iBAAgB;EACd,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG;AACR,aAAW,UAAU;AACrB,MAAI,eAAA,gBAAgB,EAAE,CAAE,GAAE,MAAM;AAChC,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO;EACL,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,OAAO,MAAM;EACb,OAAO,MAAM;EACb;EACD;;;;;;;;;;AAWH,SAAgB,YAAkC,SAAqB;CACrE,MAAM,YAAA,GAAA,MAAA,QAA4B,KAAK;CACvC,MAAM,cAAA,GAAA,MAAA,QAAoB,MAAM;CAEhC,IAAI,QAAQ,SAAS;AACrB,KAAI,CAAC,SAAS,MAAM,UAAU;AAC5B,UAAQ,SAAS;AACjB,MAAI,QAAS,mBAAkB,OAAO,cAAc;AACpD,WAAS,UAAU;;AAGrB,EAAA,GAAA,MAAA,iBAAgB;EACd,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG;AACR,aAAW,UAAU;AACrB,MAAI,eAAA,gBAAgB,EAAE,CAAE,GAAE,MAAM;AAChC,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO;;;;;AAaT,SAAgB,SACd,OACA,OACmB;AACnB,KAAI,QAAS,mBAAkB,OAAO,WAAW;CAEjD,MAAM,eAAA,GAAA,MAAA,mBAAgC;AACpC,SAAO;GACL,OAAO,MAAM,MAAM;GACnB,OAAO,MAAM,OAAO;GACrB;IACA,CAAC,OAAO,MAAM,CAAC;CAGlB,MAAM,aAAA,GAAA,MAAA,QAAmB,aAAa,CAAC;CAkBvC,MAAM,YAAA,GAAA,MAAA,uBAAA,GAAA,MAAA,cAfH,kBAA8B;AAC7B,SAAO,MAAM,gBAAgB;GAC3B,MAAM,OAAO,aAAa;GAC1B,MAAM,UAAU,UAAU;AAG1B,OAAI,KAAK,UAAU,QAAQ,SAAS,KAAK,UAAU,QAAQ,OAAO;AAChE,cAAU,UAAU;AACpB,mBAAe;;IAEjB;IAEJ,CAAC,OAAO,YAAY,CACrB,QAIO,UAAU,eACV,UAAU,QACjB;CAED,MAAM,OAAA,GAAA,MAAA,cACH,UAAgB;EAGf,MAAM,UAAsB,GAAG,QAAQ,OAAO;AAC7C,QAA4D,IAAI,QAAQ;IAE3E,CAAC,OAAO,MAAM,CACf;AAED,QAAO;EACL,OAAO,SAAS;EAChB,OAAO,SAAS;EAChB;EACD"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-model.d.ts","sourceRoot":"","sources":["../../src/react/use-model.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC,gFAAgF;AAChF,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,KAAK,CAAC,CAAC,CAAC;IAC/D,KAAK,EAAE,CAAC,CAAC;IACT,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC5B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,CAAC,CAAC;CACV;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,EAC3C,OAAO,EAAE,MAAM,CAAC,GACf,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CA0C5B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,CAwBrE;AAED,sFAAsF;AACtF,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,KAAK,EAAE,CAAC,CAAC;IACT,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;CACzB;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,EAC1D,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,KAAK,EAAE,CAAC,GACP,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAiDnB"}
1
+ {"version":3,"file":"use-model.d.ts","sourceRoot":"","sources":["../../src/react/use-model.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAoBvC,gFAAgF;AAChF,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,KAAK,CAAC,CAAC,CAAC;IAC/D,KAAK,EAAE,CAAC,CAAC;IACT,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC5B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,CAAC,CAAC;CACV;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,EAC3C,OAAO,EAAE,MAAM,CAAC,GACf,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAkD5B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,CA2BrE;AAED,sFAAsF;AACtF,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,KAAK,EAAE,CAAC,CAAC;IACT,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;CACzB;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,EAC1D,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,KAAK,EAAE,CAAC,GACP,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAkDnB"}
@@ -1,19 +1,38 @@
1
1
  import { isInitializable } from "./guards.js";
2
2
  import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
3
3
  //#region src/react/use-model.ts
4
+ var __DEV__ = typeof __MVC_KIT_DEV__ !== "undefined" && __MVC_KIT_DEV__;
5
+ function assertModelExists(model, hookName) {
6
+ if (model) return;
7
+ throw new Error(`${hookName}: received an undefined/null model. This usually means the component rendered before the ViewModel finished creating the model. Either (a) create the model in the ViewModel constructor when the initial data is available (so it exists from the first render), or (b) if the model is created in onInit() after an async fetch, guard the component render with \`if (!vm.model) return <Spinner />\` before calling this hook. Do not use \`public model!: FormModel\` — the \`!\` lies to TypeScript about runtime state.`);
8
+ }
4
9
  /**
5
10
  * Bind to a component-scoped Model with validation and dirty state exposed.
6
11
  */
7
12
  function useModel(factory) {
8
13
  const modelRef = useRef(null);
9
14
  const mountedRef = useRef(false);
10
- if (!modelRef.current || modelRef.current.disposed) modelRef.current = factory();
11
- const modelSubscribe = useCallback((onStoreChange) => modelRef.current.subscribe(onStoreChange), []);
12
- const modelSnapshot = useCallback(() => modelRef.current.state, []);
15
+ let model = modelRef.current;
16
+ if (!model || model.disposed) {
17
+ model = factory();
18
+ if (__DEV__) assertModelExists(model, "useModel");
19
+ modelRef.current = model;
20
+ }
21
+ const modelSubscribe = useCallback((onStoreChange) => {
22
+ const m = modelRef.current;
23
+ return m ? m.subscribe(onStoreChange) : () => {};
24
+ }, []);
25
+ const modelSnapshot = useCallback(() => {
26
+ const m = modelRef.current;
27
+ if (!m) throw new Error("useModel: model accessed before creation");
28
+ return m.state;
29
+ }, []);
13
30
  useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);
14
31
  useEffect(() => {
32
+ const m = modelRef.current;
33
+ if (!m) return;
15
34
  mountedRef.current = true;
16
- if (isInitializable(modelRef.current)) modelRef.current.init();
35
+ if (isInitializable(m)) m.init();
17
36
  return () => {
18
37
  mountedRef.current = false;
19
38
  setTimeout(() => {
@@ -21,7 +40,6 @@ function useModel(factory) {
21
40
  }, 0);
22
41
  };
23
42
  }, []);
24
- const model = modelRef.current;
25
43
  return {
26
44
  state: model.state,
27
45
  errors: model.errors,
@@ -41,10 +59,17 @@ function useModel(factory) {
41
59
  function useModelRef(factory) {
42
60
  const modelRef = useRef(null);
43
61
  const mountedRef = useRef(false);
44
- if (!modelRef.current || modelRef.current.disposed) modelRef.current = factory();
62
+ let model = modelRef.current;
63
+ if (!model || model.disposed) {
64
+ model = factory();
65
+ if (__DEV__) assertModelExists(model, "useModelRef");
66
+ modelRef.current = model;
67
+ }
45
68
  useEffect(() => {
69
+ const m = modelRef.current;
70
+ if (!m) return;
46
71
  mountedRef.current = true;
47
- if (isInitializable(modelRef.current)) modelRef.current.init();
72
+ if (isInitializable(m)) m.init();
48
73
  return () => {
49
74
  mountedRef.current = false;
50
75
  setTimeout(() => {
@@ -52,12 +77,13 @@ function useModelRef(factory) {
52
77
  }, 0);
53
78
  };
54
79
  }, []);
55
- return modelRef.current;
80
+ return model;
56
81
  }
57
82
  /**
58
83
  * Bind to a single Model field with surgical re-renders.
59
84
  */
60
85
  function useField(model, field) {
86
+ if (__DEV__) assertModelExists(model, "useField");
61
87
  const getSnapshot = useCallback(() => {
62
88
  return {
63
89
  value: model.state[field],
@@ -1 +1 @@
1
- {"version":3,"file":"use-model.js","names":[],"sources":["../../src/react/use-model.ts"],"sourcesContent":["import { useRef, useEffect, useSyncExternalStore, useCallback } from 'react';\nimport type { Model } from '../Model';\nimport type { ValidationErrors } from '../types';\nimport type { StateOf } from './types';\nimport { isInitializable } from './guards';\n\n/** Return type of `useModel`, providing state, validation, and model access. */\nexport interface ModelHandle<S extends object, M extends Model<S>> {\n state: S;\n errors: ValidationErrors<S>;\n valid: boolean;\n dirty: boolean;\n model: M;\n}\n\n/**\n * Bind to a component-scoped Model with validation and dirty state exposed.\n */\nexport function useModel<M extends Model<any>>(\n factory: () => M\n): ModelHandle<StateOf<M>, M> {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n if (!modelRef.current || modelRef.current.disposed) {\n modelRef.current = factory();\n }\n\n const modelSubscribe = useCallback(\n (onStoreChange: () => void) => modelRef.current!.subscribe(onStoreChange),\n []\n );\n const modelSnapshot = useCallback(\n () => modelRef.current!.state,\n []\n );\n useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);\n\n useEffect(() => {\n mountedRef.current = true;\n if (isInitializable(modelRef.current)) {\n modelRef.current.init();\n }\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n const model = modelRef.current;\n\n return {\n state: model.state,\n errors: model.errors,\n valid: model.valid,\n dirty: model.dirty,\n model,\n };\n}\n\n/**\n * Create a component-scoped Model with lifecycle management (init + dispose)\n * but NO state subscription. The parent component never re-renders from\n * model state changes.\n *\n * Designed for the per-field isolation pattern: parent creates the model\n * via `useModelRef`, children subscribe to individual fields via `useField`.\n */\nexport function useModelRef<M extends Model<any>>(factory: () => M): M {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n if (!modelRef.current || modelRef.current.disposed) {\n modelRef.current = factory();\n }\n\n useEffect(() => {\n mountedRef.current = true;\n if (isInitializable(modelRef.current)) {\n modelRef.current.init();\n }\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return modelRef.current;\n}\n\n/** Return type of `useField`, providing a single field's value, error, and setter. */\nexport interface FieldHandle<V> {\n value: V;\n error: string | undefined;\n set: (value: V) => void;\n}\n\n/**\n * Bind to a single Model field with surgical re-renders.\n */\nexport function useField<S extends object, K extends keyof S>(\n model: Model<S>,\n field: K\n): FieldHandle<S[K]> {\n // Track the field value and error for comparison\n const getSnapshot = useCallback(() => {\n return {\n value: model.state[field],\n error: model.errors[field],\n };\n }, [model, field]);\n\n // Use object comparison for subscription\n const cachedRef = useRef(getSnapshot());\n\n const subscribe = useCallback(\n (onStoreChange: () => void) => {\n return model.subscribe(() => {\n const next = getSnapshot();\n const current = cachedRef.current;\n\n // Only trigger re-render if field value or error changed\n if (next.value !== current.value || next.error !== current.error) {\n cachedRef.current = next;\n onStoreChange();\n }\n });\n },\n [model, getSnapshot]\n );\n\n const snapshot = useSyncExternalStore(\n subscribe,\n () => cachedRef.current,\n () => cachedRef.current\n );\n\n const set = useCallback(\n (value: S[K]) => {\n // Access the protected set method through type assertion\n // The Model subclass should expose a setter method\n const partial: Partial<S> = { [field]: value } as unknown as Partial<S>;\n (model as unknown as { set: (partial: Partial<S>) => void }).set(partial);\n },\n [model, field]\n );\n\n return {\n value: snapshot.value,\n error: snapshot.error,\n set,\n };\n}\n"],"mappings":";;;;;;AAkBA,SAAgB,SACd,SAC4B;CAC5B,MAAM,WAAW,OAAiB,KAAK;CACvC,MAAM,aAAa,OAAO,MAAM;AAEhC,KAAI,CAAC,SAAS,WAAW,SAAS,QAAQ,SACxC,UAAS,UAAU,SAAS;CAG9B,MAAM,iBAAiB,aACpB,kBAA8B,SAAS,QAAS,UAAU,cAAc,EACzE,EAAE,CACH;CACD,MAAM,gBAAgB,kBACd,SAAS,QAAS,OACxB,EAAE,CACH;AACD,sBAAqB,gBAAgB,eAAe,cAAc;AAElE,iBAAgB;AACd,aAAW,UAAU;AACrB,MAAI,gBAAgB,SAAS,QAAQ,CACnC,UAAS,QAAQ,MAAM;AAEzB,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;CAEN,MAAM,QAAQ,SAAS;AAEvB,QAAO;EACL,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,OAAO,MAAM;EACb,OAAO,MAAM;EACb;EACD;;;;;;;;;;AAWH,SAAgB,YAAkC,SAAqB;CACrE,MAAM,WAAW,OAAiB,KAAK;CACvC,MAAM,aAAa,OAAO,MAAM;AAEhC,KAAI,CAAC,SAAS,WAAW,SAAS,QAAQ,SACxC,UAAS,UAAU,SAAS;AAG9B,iBAAgB;AACd,aAAW,UAAU;AACrB,MAAI,gBAAgB,SAAS,QAAQ,CACnC,UAAS,QAAQ,MAAM;AAEzB,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO,SAAS;;;;;AAalB,SAAgB,SACd,OACA,OACmB;CAEnB,MAAM,cAAc,kBAAkB;AACpC,SAAO;GACL,OAAO,MAAM,MAAM;GACnB,OAAO,MAAM,OAAO;GACrB;IACA,CAAC,OAAO,MAAM,CAAC;CAGlB,MAAM,YAAY,OAAO,aAAa,CAAC;CAkBvC,MAAM,WAAW,qBAhBC,aACf,kBAA8B;AAC7B,SAAO,MAAM,gBAAgB;GAC3B,MAAM,OAAO,aAAa;GAC1B,MAAM,UAAU,UAAU;AAG1B,OAAI,KAAK,UAAU,QAAQ,SAAS,KAAK,UAAU,QAAQ,OAAO;AAChE,cAAU,UAAU;AACpB,mBAAe;;IAEjB;IAEJ,CAAC,OAAO,YAAY,CACrB,QAIO,UAAU,eACV,UAAU,QACjB;CAED,MAAM,MAAM,aACT,UAAgB;EAGf,MAAM,UAAsB,GAAG,QAAQ,OAAO;AAC7C,QAA4D,IAAI,QAAQ;IAE3E,CAAC,OAAO,MAAM,CACf;AAED,QAAO;EACL,OAAO,SAAS;EAChB,OAAO,SAAS;EAChB;EACD"}
1
+ {"version":3,"file":"use-model.js","names":[],"sources":["../../src/react/use-model.ts"],"sourcesContent":["import { useRef, useEffect, useSyncExternalStore, useCallback } from 'react';\nimport type { Model } from '../Model';\nimport type { ValidationErrors } from '../types';\nimport type { StateOf } from './types';\nimport { isInitializable } from './guards';\n\nconst __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;\n\nfunction assertModelExists(\n model: unknown,\n hookName: 'useModel' | 'useModelRef' | 'useField',\n): asserts model {\n if (model) return;\n throw new Error(\n `${hookName}: received an undefined/null model. ` +\n 'This usually means the component rendered before the ViewModel finished creating the model. ' +\n 'Either (a) create the model in the ViewModel constructor when the initial data is available ' +\n '(so it exists from the first render), or (b) if the model is created in onInit() after an async ' +\n 'fetch, guard the component render with `if (!vm.model) return <Spinner />` before calling this hook. ' +\n 'Do not use `public model!: FormModel` — the `!` lies to TypeScript about runtime state.',\n );\n}\n\n/** Return type of `useModel`, providing state, validation, and model access. */\nexport interface ModelHandle<S extends object, M extends Model<S>> {\n state: S;\n errors: ValidationErrors<S>;\n valid: boolean;\n dirty: boolean;\n model: M;\n}\n\n/**\n * Bind to a component-scoped Model with validation and dirty state exposed.\n */\nexport function useModel<M extends Model<any>>(\n factory: () => M\n): ModelHandle<StateOf<M>, M> {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n let model = modelRef.current;\n if (!model || model.disposed) {\n model = factory();\n if (__DEV__) assertModelExists(model, 'useModel');\n modelRef.current = model;\n }\n\n const modelSubscribe = useCallback(\n (onStoreChange: () => void) => {\n const m = modelRef.current;\n return m ? m.subscribe(onStoreChange) : () => {};\n },\n []\n );\n const modelSnapshot = useCallback(\n () => {\n const m = modelRef.current;\n if (!m) throw new Error('useModel: model accessed before creation');\n return m.state;\n },\n []\n );\n useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);\n\n useEffect(() => {\n const m = modelRef.current;\n if (!m) return;\n mountedRef.current = true;\n if (isInitializable(m)) m.init();\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return {\n state: model.state,\n errors: model.errors,\n valid: model.valid,\n dirty: model.dirty,\n model,\n };\n}\n\n/**\n * Create a component-scoped Model with lifecycle management (init + dispose)\n * but NO state subscription. The parent component never re-renders from\n * model state changes.\n *\n * Designed for the per-field isolation pattern: parent creates the model\n * via `useModelRef`, children subscribe to individual fields via `useField`.\n */\nexport function useModelRef<M extends Model<any>>(factory: () => M): M {\n const modelRef = useRef<M | null>(null);\n const mountedRef = useRef(false);\n\n let model = modelRef.current;\n if (!model || model.disposed) {\n model = factory();\n if (__DEV__) assertModelExists(model, 'useModelRef');\n modelRef.current = model;\n }\n\n useEffect(() => {\n const m = modelRef.current;\n if (!m) return;\n mountedRef.current = true;\n if (isInitializable(m)) m.init();\n return () => {\n mountedRef.current = false;\n setTimeout(() => {\n if (!mountedRef.current) {\n modelRef.current?.dispose();\n }\n }, 0);\n };\n }, []);\n\n return model;\n}\n\n/** Return type of `useField`, providing a single field's value, error, and setter. */\nexport interface FieldHandle<V> {\n value: V;\n error: string | undefined;\n set: (value: V) => void;\n}\n\n/**\n * Bind to a single Model field with surgical re-renders.\n */\nexport function useField<S extends object, K extends keyof S>(\n model: Model<S>,\n field: K\n): FieldHandle<S[K]> {\n if (__DEV__) assertModelExists(model, 'useField');\n // Track the field value and error for comparison\n const getSnapshot = useCallback(() => {\n return {\n value: model.state[field],\n error: model.errors[field],\n };\n }, [model, field]);\n\n // Use object comparison for subscription\n const cachedRef = useRef(getSnapshot());\n\n const subscribe = useCallback(\n (onStoreChange: () => void) => {\n return model.subscribe(() => {\n const next = getSnapshot();\n const current = cachedRef.current;\n\n // Only trigger re-render if field value or error changed\n if (next.value !== current.value || next.error !== current.error) {\n cachedRef.current = next;\n onStoreChange();\n }\n });\n },\n [model, getSnapshot]\n );\n\n const snapshot = useSyncExternalStore(\n subscribe,\n () => cachedRef.current,\n () => cachedRef.current\n );\n\n const set = useCallback(\n (value: S[K]) => {\n // Access the protected set method through type assertion\n // The Model subclass should expose a setter method\n const partial: Partial<S> = { [field]: value } as unknown as Partial<S>;\n (model as unknown as { set: (partial: Partial<S>) => void }).set(partial);\n },\n [model, field]\n );\n\n return {\n value: snapshot.value,\n error: snapshot.error,\n set,\n };\n}\n"],"mappings":";;;AAMA,IAAM,UAAU,OAAO,oBAAoB,eAAe;AAE1D,SAAS,kBACP,OACA,UACe;AACf,KAAI,MAAO;AACX,OAAM,IAAI,MACR,GAAG,SAAS,ggBAMb;;;;;AAeH,SAAgB,SACd,SAC4B;CAC5B,MAAM,WAAW,OAAiB,KAAK;CACvC,MAAM,aAAa,OAAO,MAAM;CAEhC,IAAI,QAAQ,SAAS;AACrB,KAAI,CAAC,SAAS,MAAM,UAAU;AAC5B,UAAQ,SAAS;AACjB,MAAI,QAAS,mBAAkB,OAAO,WAAW;AACjD,WAAS,UAAU;;CAGrB,MAAM,iBAAiB,aACpB,kBAA8B;EAC7B,MAAM,IAAI,SAAS;AACnB,SAAO,IAAI,EAAE,UAAU,cAAc,SAAS;IAEhD,EAAE,CACH;CACD,MAAM,gBAAgB,kBACd;EACJ,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG,OAAM,IAAI,MAAM,2CAA2C;AACnE,SAAO,EAAE;IAEX,EAAE,CACH;AACD,sBAAqB,gBAAgB,eAAe,cAAc;AAElE,iBAAgB;EACd,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG;AACR,aAAW,UAAU;AACrB,MAAI,gBAAgB,EAAE,CAAE,GAAE,MAAM;AAChC,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO;EACL,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,OAAO,MAAM;EACb,OAAO,MAAM;EACb;EACD;;;;;;;;;;AAWH,SAAgB,YAAkC,SAAqB;CACrE,MAAM,WAAW,OAAiB,KAAK;CACvC,MAAM,aAAa,OAAO,MAAM;CAEhC,IAAI,QAAQ,SAAS;AACrB,KAAI,CAAC,SAAS,MAAM,UAAU;AAC5B,UAAQ,SAAS;AACjB,MAAI,QAAS,mBAAkB,OAAO,cAAc;AACpD,WAAS,UAAU;;AAGrB,iBAAgB;EACd,MAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG;AACR,aAAW,UAAU;AACrB,MAAI,gBAAgB,EAAE,CAAE,GAAE,MAAM;AAChC,eAAa;AACX,cAAW,UAAU;AACrB,oBAAiB;AACf,QAAI,CAAC,WAAW,QACd,UAAS,SAAS,SAAS;MAE5B,EAAE;;IAEN,EAAE,CAAC;AAEN,QAAO;;;;;AAaT,SAAgB,SACd,OACA,OACmB;AACnB,KAAI,QAAS,mBAAkB,OAAO,WAAW;CAEjD,MAAM,cAAc,kBAAkB;AACpC,SAAO;GACL,OAAO,MAAM,MAAM;GACnB,OAAO,MAAM,OAAO;GACrB;IACA,CAAC,OAAO,MAAM,CAAC;CAGlB,MAAM,YAAY,OAAO,aAAa,CAAC;CAkBvC,MAAM,WAAW,qBAhBC,aACf,kBAA8B;AAC7B,SAAO,MAAM,gBAAgB;GAC3B,MAAM,OAAO,aAAa;GAC1B,MAAM,UAAU,UAAU;AAG1B,OAAI,KAAK,UAAU,QAAQ,SAAS,KAAK,UAAU,QAAQ,OAAO;AAChE,cAAU,UAAU;AACpB,mBAAe;;IAEjB;IAEJ,CAAC,OAAO,YAAY,CACrB,QAIO,UAAU,eACV,UAAU,QACjB;CAED,MAAM,MAAM,aACT,UAAgB;EAGf,MAAM,UAAsB,GAAG,QAAQ,OAAO;AAC7C,QAA4D,IAAI,QAAQ;IAE3E,CAAC,OAAO,MAAM,CACf;AAED,QAAO;EACL,OAAO,SAAS;EAChB,OAAO,SAAS;EAChB;EACD"}
@@ -10,18 +10,19 @@ function useSubscribeOnly(target) {
10
10
  const ref = (0, react.useRef)(null);
11
11
  if (!ref.current || ref.current.target !== target) {
12
12
  const version = { current: ref.current?.version ?? 0 };
13
- ref.current = {
13
+ const entry = {
14
14
  target,
15
15
  version: version.current,
16
16
  subscribe: (onStoreChange) => {
17
17
  return target.subscribe(() => {
18
18
  version.current++;
19
- ref.current.version = version.current;
19
+ entry.version = version.current;
20
20
  onStoreChange();
21
21
  });
22
22
  },
23
23
  getSnapshot: () => version.current
24
24
  };
25
+ ref.current = entry;
25
26
  }
26
27
  (0, react.useSyncExternalStore)(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
27
28
  }
@@ -1 +1 @@
1
- {"version":3,"file":"use-subscribe-only.cjs","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n ref.current = {\n target,\n version: version.current,\n subscribe: (onStoreChange: () => void) => {\n return target.subscribe(() => {\n version.current++;\n ref.current!.version = version.current;\n onStoreChange();\n });\n },\n getSnapshot: () => version.current,\n };\n }\n\n useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);\n}\n"],"mappings":";;AASA,IAAM,wBAAwB;;;;;;AAO9B,SAAgB,iBACd,QACM;CACN,MAAM,OAAA,GAAA,MAAA,QAAsC,KAAK;AAEjD,KAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,QAAQ;EACjD,MAAM,UAAU,EAAE,SAAS,IAAI,SAAS,WAAW,GAAG;AACtD,MAAI,UAAU;GACZ;GACA,SAAS,QAAQ;GACjB,YAAY,kBAA8B;AACxC,WAAO,OAAO,gBAAgB;AAC5B,aAAQ;AACR,SAAI,QAAS,UAAU,QAAQ;AAC/B,oBAAe;MACf;;GAEJ,mBAAmB,QAAQ;GAC5B;;AAGH,EAAA,GAAA,MAAA,sBAAqB,IAAI,QAAQ,WAAW,IAAI,QAAQ,aAAa,gBAAgB"}
1
+ {"version":3,"file":"use-subscribe-only.cjs","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n const entry: SubscribeOnlyRef = {\n target,\n version: version.current,\n subscribe: (onStoreChange: () => void) => {\n return target.subscribe(() => {\n version.current++;\n entry.version = version.current;\n onStoreChange();\n });\n },\n getSnapshot: () => version.current,\n };\n ref.current = entry;\n }\n\n useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);\n}\n"],"mappings":";;AASA,IAAM,wBAAwB;;;;;;AAO9B,SAAgB,iBACd,QACM;CACN,MAAM,OAAA,GAAA,MAAA,QAAsC,KAAK;AAEjD,KAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,QAAQ;EACjD,MAAM,UAAU,EAAE,SAAS,IAAI,SAAS,WAAW,GAAG;EACtD,MAAM,QAA0B;GAC9B;GACA,SAAS,QAAQ;GACjB,YAAY,kBAA8B;AACxC,WAAO,OAAO,gBAAgB;AAC5B,aAAQ;AACR,WAAM,UAAU,QAAQ;AACxB,oBAAe;MACf;;GAEJ,mBAAmB,QAAQ;GAC5B;AACD,MAAI,UAAU;;AAGhB,EAAA,GAAA,MAAA,sBAAqB,IAAI,QAAQ,WAAW,IAAI,QAAQ,aAAa,gBAAgB"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-subscribe-only.d.ts","sourceRoot":"","sources":["../../src/react/use-subscribe-only.ts"],"names":[],"mappings":"AAWA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;CAAE,GAChD,IAAI,CAoBN"}
1
+ {"version":3,"file":"use-subscribe-only.d.ts","sourceRoot":"","sources":["../../src/react/use-subscribe-only.ts"],"names":[],"mappings":"AAWA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;CAAE,GAChD,IAAI,CAqBN"}
@@ -10,18 +10,19 @@ function useSubscribeOnly(target) {
10
10
  const ref = useRef(null);
11
11
  if (!ref.current || ref.current.target !== target) {
12
12
  const version = { current: ref.current?.version ?? 0 };
13
- ref.current = {
13
+ const entry = {
14
14
  target,
15
15
  version: version.current,
16
16
  subscribe: (onStoreChange) => {
17
17
  return target.subscribe(() => {
18
18
  version.current++;
19
- ref.current.version = version.current;
19
+ entry.version = version.current;
20
20
  onStoreChange();
21
21
  });
22
22
  },
23
23
  getSnapshot: () => version.current
24
24
  };
25
+ ref.current = entry;
25
26
  }
26
27
  useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
27
28
  }
@@ -1 +1 @@
1
- {"version":3,"file":"use-subscribe-only.js","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n ref.current = {\n target,\n version: version.current,\n subscribe: (onStoreChange: () => void) => {\n return target.subscribe(() => {\n version.current++;\n ref.current!.version = version.current;\n onStoreChange();\n });\n },\n getSnapshot: () => version.current,\n };\n }\n\n useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);\n}\n"],"mappings":";;AASA,IAAM,wBAAwB;;;;;;AAO9B,SAAgB,iBACd,QACM;CACN,MAAM,MAAM,OAAgC,KAAK;AAEjD,KAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,QAAQ;EACjD,MAAM,UAAU,EAAE,SAAS,IAAI,SAAS,WAAW,GAAG;AACtD,MAAI,UAAU;GACZ;GACA,SAAS,QAAQ;GACjB,YAAY,kBAA8B;AACxC,WAAO,OAAO,gBAAgB;AAC5B,aAAQ;AACR,SAAI,QAAS,UAAU,QAAQ;AAC/B,oBAAe;MACf;;GAEJ,mBAAmB,QAAQ;GAC5B;;AAGH,sBAAqB,IAAI,QAAQ,WAAW,IAAI,QAAQ,aAAa,gBAAgB"}
1
+ {"version":3,"file":"use-subscribe-only.js","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n const entry: SubscribeOnlyRef = {\n target,\n version: version.current,\n subscribe: (onStoreChange: () => void) => {\n return target.subscribe(() => {\n version.current++;\n entry.version = version.current;\n onStoreChange();\n });\n },\n getSnapshot: () => version.current,\n };\n ref.current = entry;\n }\n\n useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);\n}\n"],"mappings":";;AASA,IAAM,wBAAwB;;;;;;AAO9B,SAAgB,iBACd,QACM;CACN,MAAM,MAAM,OAAgC,KAAK;AAEjD,KAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,QAAQ;EACjD,MAAM,UAAU,EAAE,SAAS,IAAI,SAAS,WAAW,GAAG;EACtD,MAAM,QAA0B;GAC9B;GACA,SAAS,QAAQ;GACjB,YAAY,kBAA8B;AACxC,WAAO,OAAO,gBAAgB;AAC5B,aAAQ;AACR,WAAM,UAAU,QAAQ;AACxB,oBAAe;MACf;;GAEJ,mBAAmB,QAAQ;GAC5B;AACD,MAAI,UAAU;;AAGhB,sBAAqB,IAAI,QAAQ,WAAW,IAAI,QAAQ,aAAa,gBAAgB"}
@@ -3,6 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
3
3
 
4
4
  export function AdminPage() {
5
5
  const [state, vm] = useSingleton(AuthViewModel);
6
+ const { user } = state;
7
+ if (!user) return null; // AuthGuard renders this page only when signed in
6
8
 
7
9
  // Role check is done inside the page, not via a route wrapper.
8
10
  // This keeps routing simple and the access-denied message inline.
@@ -13,7 +15,7 @@ export function AdminPage() {
13
15
  <h2>Access Denied</h2>
14
16
  <p>
15
17
  You are signed in as <strong>{vm.displayName}</strong> with
16
- the <span className={`badge badge-${state.user!.role}`}>{state.user!.role}</span> role.
18
+ the <span className={`badge badge-${user.role}`}>{user.role}</span> role.
17
19
  </p>
18
20
  <p>This page requires the <span className="badge badge-admin">admin</span> role.</p>
19
21
  </div>
@@ -3,6 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
3
3
 
4
4
  export function DashboardPage() {
5
5
  const [state, vm] = useSingleton(AuthViewModel);
6
+ const { user } = state;
7
+ if (!user) return null; // AuthGuard renders this page only when signed in
6
8
 
7
9
  return (
8
10
  <div className="page-content">
@@ -11,8 +13,8 @@ export function DashboardPage() {
11
13
  <div className="card" style={{ marginBottom: '1.5rem' }}>
12
14
  <h2 style={{ marginBottom: '0.5rem' }}>Welcome, {vm.displayName}!</h2>
13
15
  <p style={{ color: 'var(--color-text-secondary)' }}>
14
- You are signed in as <strong>{state.user!.email}</strong> with
15
- the <span className={`badge badge-${state.user!.role}`}>{state.user!.role}</span> role.
16
+ You are signed in as <strong>{user.email}</strong> with
17
+ the <span className={`badge badge-${user.role}`}>{user.role}</span> role.
16
18
  </p>
17
19
  </div>
18
20
 
@@ -3,7 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
3
3
 
4
4
  export function ProfilePage() {
5
5
  const [state, vm] = useSingleton(AuthViewModel);
6
- const user = state.user!;
6
+ const { user } = state;
7
+ if (!user) return null; // AuthGuard renders this page only when signed in
7
8
 
8
9
  return (
9
10
  <div className="page-content">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mvc-kit",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "description": "Zero-magic, class-based reactive ViewModel library",
5
5
  "type": "module",
6
6
  "main": "./dist/mvc-kit.cjs",
package/src/Model.md CHANGED
@@ -386,11 +386,22 @@ The returned `FieldHandle` provides:
386
386
 
387
387
  ## Model Inside a ViewModel
388
388
 
389
- The typical pattern for a form page: the ViewModel handles async operations and coordination, the Model handles editing state.
389
+ The typical pattern for a form: the ViewModel handles async operations and coordination, the Model handles editing state. There are two shapes, and the right choice depends on whether the form's initial data is already in hand.
390
+
391
+ | If the initial data is... | Shape |
392
+ |---|---|
393
+ | **Available at construction** (create modal, edit modal taking an entity prop) | Create the Model in the **constructor** as `readonly`. Non-nullable, no guard. |
394
+ | **Fetched by ID** after mount (edit page that loads from the server) | Declare it `\| null = null` and create it in `onInit()`. Component must guard. |
395
+
396
+ > **Never** write `public model!: FormModel`. The `!` definite-assignment assertion lies to TypeScript about runtime state — first render runs before `onInit`, so `vm.model` is `undefined`, and `useField` / `useModel` crash with "Cannot read properties of undefined."
397
+
398
+ ### Shape 1 — Data available at construction
399
+
400
+ The ViewModel receives everything it needs via `useLocal`'s initial state. Create the Model in the constructor so it exists from the first render onward.
390
401
 
391
402
  ```typescript
392
403
  interface EditState {
393
- draft: UserState | null;
404
+ existing: UserState | null;
394
405
  }
395
406
 
396
407
  interface EditEvents {
@@ -398,7 +409,47 @@ interface EditEvents {
398
409
  }
399
410
 
400
411
  class EditUserViewModel extends ViewModel<EditState, EditEvents> {
401
- public model!: UserFormModel;
412
+ // `readonly` refers to the *reference* — the model's state still changes via set().
413
+ public readonly model: UserFormModel;
414
+ private service = singleton(UserService);
415
+
416
+ constructor(initialState: EditState) {
417
+ super(initialState);
418
+ this.model = new UserFormModel(initialState.existing ?? INITIAL_FORM_STATE);
419
+ }
420
+
421
+ async save() {
422
+ if (!this.model.valid) return;
423
+ const result = await this.service.save(this.model.state, this.disposeSignal);
424
+ this.model.commit();
425
+ this.emit('saved', { id: result.id });
426
+ }
427
+
428
+ protected onDispose() {
429
+ this.model.dispose();
430
+ }
431
+ }
432
+ ```
433
+
434
+ ```tsx
435
+ function EditUserModal({ existing, onClose }: Props) {
436
+ const [, vm] = useLocal(EditUserViewModel, { existing });
437
+ const { loading: saving } = vm.async.save;
438
+
439
+ useEvent(vm, 'saved', onClose);
440
+
441
+ // No guard — vm.model is non-null from the first render.
442
+ return <EditUserForm model={vm.model} onSave={vm.save} saving={saving} />;
443
+ }
444
+ ```
445
+
446
+ ### Shape 2 — Data fetched by ID
447
+
448
+ Only an ID is known at construction; the entity is loaded in `onInit()`. The Model is nullable until the fetch resolves.
449
+
450
+ ```typescript
451
+ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
452
+ public model: UserFormModel | null = null;
402
453
  private service = singleton(UserService);
403
454
 
404
455
  protected async onInit() {
@@ -408,7 +459,7 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
408
459
  }
409
460
 
410
461
  async save() {
411
- if (!this.model.valid) return;
462
+ if (!this.model || !this.model.valid) return;
412
463
  await this.service.update(this.userId, this.model.state, this.disposeSignal);
413
464
  this.model.commit();
414
465
  this.emit('saved', { id: this.userId });
@@ -420,8 +471,6 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
420
471
  }
421
472
  ```
422
473
 
423
- The component:
424
-
425
474
  ```tsx
426
475
  function EditUserPage() {
427
476
  const [state, vm] = useLocal(EditUserViewModel, { draft: null });
package/src/Service.md CHANGED
@@ -92,7 +92,10 @@ class ConfigService extends Service {
92
92
  }
93
93
 
94
94
  getConfig(): AppConfig {
95
- return this.config!;
95
+ if (!this.config) {
96
+ throw new Error('ConfigService not initialized — await init() before getConfig()');
97
+ }
98
+ return this.config;
96
99
  }
97
100
  }
98
101
 
@@ -153,13 +153,9 @@ export function DataTable<T>({
153
153
  // ── Resolve props ──
154
154
  const resolvedSelection = selection ? resolveSelectionProp(selection) : undefined;
155
155
 
156
- let resolvedSorts: readonly SortDescriptor[] | undefined;
157
- let resolvedOnSort: ((key: string) => void) | undefined;
158
- if (sort) {
159
- const resolved = resolveSortProp(sort, onSort);
160
- resolvedSorts = resolved.sorts;
161
- resolvedOnSort = resolved.onSort;
162
- }
156
+ const resolvedSort = sort ? resolveSortProp(sort, onSort) : undefined;
157
+ const resolvedSorts = resolvedSort?.sorts;
158
+ const resolvedOnSort = resolvedSort?.onSort;
163
159
 
164
160
  let displayItems = items;
165
161
  let paginationInfo: PaginationInfo | undefined;
@@ -171,8 +167,8 @@ export function DataTable<T>({
171
167
 
172
168
  // Selection: check indeterminate state
173
169
  const allKeys = resolvedSelection ? displayItems.map(item => keyOf(item)) : [];
174
- const allSelected = resolvedSelection && allKeys.length > 0 && allKeys.every(k => resolvedSelection!.selected.has(k));
175
- const someSelected = resolvedSelection && allKeys.some(k => resolvedSelection!.selected.has(k));
170
+ const allSelected = !!resolvedSelection && allKeys.length > 0 && allKeys.every(k => resolvedSelection.selected.has(k));
171
+ const someSelected = !!resolvedSelection && allKeys.some(k => resolvedSelection.selected.has(k));
176
172
 
177
173
  return (
178
174
  <div data-component="data-table" className={className}>
@@ -187,7 +183,7 @@ export function DataTable<T>({
187
183
  ref={(el) => {
188
184
  if (el) el.indeterminate = !!someSelected && !allSelected;
189
185
  }}
190
- onChange={() => resolvedSelection!.onToggleAll(allKeys)}
186
+ onChange={() => resolvedSelection.onToggleAll(allKeys)}
191
187
  aria-label="Select all"
192
188
  />
193
189
  </th>
@@ -208,13 +204,13 @@ export function DataTable<T>({
208
204
  aria-sort={isSortable ? getAriaSortValue(col.key, resolvedSorts) : undefined}
209
205
  >
210
206
  {isSortable ? (
211
- <button type="button" onClick={() => resolvedOnSort!(col.key)}>
207
+ <button type="button" onClick={() => resolvedOnSort(col.key)}>
212
208
  {col.header}
213
209
  {renderSortIndicator?.({
214
210
  active: isActive,
215
211
  direction: sortDesc?.direction ?? 'asc',
216
212
  index: sortIndex,
217
- onToggle: () => resolvedOnSort!(col.key),
213
+ onToggle: () => resolvedOnSort(col.key),
218
214
  })}
219
215
  </button>
220
216
  ) : (
@@ -237,7 +233,7 @@ export function DataTable<T>({
237
233
  <input
238
234
  type="checkbox"
239
235
  checked={!!isSelected}
240
- onChange={() => resolvedSelection!.onToggle(key)}
236
+ onChange={() => resolvedSelection.onToggle(key)}
241
237
  aria-label={`Select row ${key}`}
242
238
  />
243
239
  </td>
@@ -1,6 +1,8 @@
1
1
  import { useSyncExternalStore, useRef } from 'react';
2
2
  import type { Subscribable } from '../types';
3
3
 
4
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
5
+
4
6
  function hasAsyncSubscription(obj: unknown): obj is { subscribeAsync(cb: () => void): () => void } {
5
7
  return (
6
8
  obj !== null &&
@@ -27,24 +29,32 @@ interface InstanceRef<S> {
27
29
  * trigger React re-renders.
28
30
  */
29
31
  export function useInstance<S>(subscribable: Subscribable<S>): S {
32
+ if (__DEV__ && !subscribable) {
33
+ throw new Error(
34
+ 'useInstance: received an undefined/null subscribable. ' +
35
+ 'Make sure the instance is created synchronously (in a parent ViewModel\'s constructor or as a singleton) ' +
36
+ 'before the component that calls useInstance renders. ' +
37
+ 'If it\'s created in onInit(), guard the component with `if (!vm.child) return <Spinner />` first.',
38
+ );
39
+ }
30
40
  const ref = useRef<InstanceRef<S> | null>(null);
31
41
 
32
42
  if (!ref.current || ref.current.subscribable !== subscribable) {
33
43
  const version = { current: ref.current?.version ?? 0 };
34
- ref.current = {
44
+ const entry: InstanceRef<S> = {
35
45
  version: version.current,
36
46
  subscribable,
37
47
  subscribe: (onStoreChange: () => void) => {
38
48
  const unsub1 = subscribable.subscribe(() => {
39
49
  version.current++;
40
- ref.current!.version = version.current;
50
+ entry.version = version.current;
41
51
  onStoreChange();
42
52
  });
43
53
  let unsub2: (() => void) | undefined;
44
54
  if (hasAsyncSubscription(subscribable)) {
45
55
  unsub2 = subscribable.subscribeAsync(() => {
46
56
  version.current++;
47
- ref.current!.version = version.current;
57
+ entry.version = version.current;
48
58
  onStoreChange();
49
59
  });
50
60
  }
@@ -52,6 +62,7 @@ export function useInstance<S>(subscribable: Subscribable<S>): S {
52
62
  },
53
63
  getSnapshot: () => version.current,
54
64
  };
65
+ ref.current = entry;
55
66
  }
56
67
 
57
68
  useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
@@ -133,7 +133,8 @@ export function useLocal<T extends Disposable, S = StateOf<T>>(
133
133
 
134
134
  // ── Effect: init + deferred cleanup ──
135
135
  useEffect(() => {
136
- const instance = instanceRef.current!; // capture for cleanup closure
136
+ const instance = instanceRef.current;
137
+ if (!instance) return;
137
138
  mountedRef.current = true;
138
139
  if (isInitializable(instance)) {
139
140
  instance.init();