@takazudo/zfb 4.3.3 → 4.5.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 (38) hide show
  1. package/README.md +14 -0
  2. package/dist/css.d.ts +45 -0
  3. package/dist/css.js +137 -0
  4. package/dist/css.js.map +1 -0
  5. package/dist/zudo-react/dom-bindings.js +11 -5
  6. package/dist/zudo-react/dom-bindings.js.map +1 -1
  7. package/dist/zudo-react/error-boundary-client.d.ts +3 -0
  8. package/dist/zudo-react/error-boundary-client.js +232 -0
  9. package/dist/zudo-react/error-boundary-client.js.map +1 -0
  10. package/dist/zudo-react/error-boundary-type.d.ts +8 -0
  11. package/dist/zudo-react/error-boundary-type.js +25 -0
  12. package/dist/zudo-react/error-boundary-type.js.map +1 -0
  13. package/dist/zudo-react/error-boundary.d.ts +8 -0
  14. package/dist/zudo-react/error-boundary.js +13 -0
  15. package/dist/zudo-react/error-boundary.js.map +1 -0
  16. package/dist/zudo-react/form-recovery.d.ts +2 -0
  17. package/dist/zudo-react/form-recovery.js +116 -0
  18. package/dist/zudo-react/form-recovery.js.map +1 -0
  19. package/dist/zudo-react/forms.d.ts +38 -4
  20. package/dist/zudo-react/forms.js +143 -172
  21. package/dist/zudo-react/forms.js.map +1 -1
  22. package/dist/zudo-react/hydrate.d.ts +68 -0
  23. package/dist/zudo-react/hydrate.js +177 -89
  24. package/dist/zudo-react/hydrate.js.map +1 -1
  25. package/dist/zudo-react/index.d.ts +1 -0
  26. package/dist/zudo-react/index.js +1 -0
  27. package/dist/zudo-react/index.js.map +1 -1
  28. package/dist/zudo-react/render-html.js +55 -10
  29. package/dist/zudo-react/render-html.js.map +1 -1
  30. package/dist/zudo-react/scope.d.ts +21 -13
  31. package/dist/zudo-react/scope.js +141 -113
  32. package/dist/zudo-react/scope.js.map +1 -1
  33. package/dist/zudo-react/style.d.ts +0 -1
  34. package/dist/zudo-react/style.js +11 -503
  35. package/dist/zudo-react/style.js.map +1 -1
  36. package/dist/zudo-react/vocabulary.js +12 -49
  37. package/dist/zudo-react/vocabulary.js.map +1 -1
  38. package/package.json +11 -7
@@ -0,0 +1,116 @@
1
+ function within(scope, parents) {
2
+ for (let current = scope; current; current = current.parent)
3
+ if (parents.includes(current))
4
+ return true;
5
+ return false;
6
+ }
7
+ // Optional policy uses the owning renderer's ledger and errors across package copies.
8
+ export function createFormRecovery(container, host) {
9
+ const { controlOwners, error, batch } = host;
10
+ return {
11
+ prepare(plans, map, mode, replacing, parent) {
12
+ const installed = [...(controlOwners.get(container.ownerDocument) ?? [])].filter((entry) => entry.root === container && entry.scope.active && !within(entry.scope, replacing));
13
+ // Fresh boundary controls still sit in the provisional tree during preflight.
14
+ // Resolve their ancestor form through the hydration map for group identity.
15
+ const plannedForms = new Map();
16
+ for (const plan of plans) {
17
+ const node = map.get(plan.element);
18
+ if (node.localName === "form" && node.id && !plannedForms.has(node.id))
19
+ plannedForms.set(node.id, node);
20
+ }
21
+ const form = (node) => {
22
+ const id = node.getAttribute("form");
23
+ if (id !== null) {
24
+ const target = plannedForms.get(id) ?? container.ownerDocument.getElementById(id);
25
+ return target instanceof Element && target.localName === "form" ? target : null;
26
+ }
27
+ const ancestor = node.closest("form");
28
+ if (ancestor)
29
+ return map.get(ancestor) ?? ancestor;
30
+ return parent instanceof Element
31
+ ? parent.localName === "form"
32
+ ? parent
33
+ : parent.closest("form")
34
+ : null;
35
+ };
36
+ return { installed, form };
37
+ },
38
+ validate(groupByModel, mode, { installed, form }) {
39
+ for (const [model, group] of groupByModel) {
40
+ for (const binding of group) {
41
+ const radio = binding.node;
42
+ if (binding.kind === "radio" &&
43
+ installed.some((entry) => entry.binding.kind === "radio" &&
44
+ entry.name === radio.name &&
45
+ entry.form === form(radio) &&
46
+ entry.binding.model !== model))
47
+ error("ZR_MODEL_CONFLICT", binding.path, "same-name radios use different models");
48
+ }
49
+ const related = installed
50
+ .filter((entry) => entry.binding.model === model)
51
+ .map((entry) => entry.binding);
52
+ const complete = [...group, ...related];
53
+ for (const binding of group)
54
+ binding.group = complete;
55
+ const radios = complete.every((binding) => binding.kind === "radio");
56
+ if (complete.some((binding) => binding.kind === "radio") && !radios)
57
+ error("ZR_MODEL_CONFLICT", group[0].path, "radio and non-radio share model");
58
+ if (radios) {
59
+ const nodes = complete.map((binding) => binding.node);
60
+ const names = new Set(nodes.map((node) => `${node.name}\u0000${node.getAttribute("form") ?? ""}`));
61
+ const optionValues = nodes.map((node) => node.value);
62
+ if (names.size !== 1 ||
63
+ new Set(nodes.map(form)).size !== 1 ||
64
+ new Set(optionValues).size !== optionValues.length)
65
+ error("ZR_MODEL_CONFLICT", group[0].path, "radio group membership or duplicate value");
66
+ if ((mode === "mount" || group.every((binding) => binding.fresh)) &&
67
+ model.value !== null &&
68
+ !optionValues.includes(model.value))
69
+ error("ZR_MODEL_VALUE", group[0].path, "radio model has no option");
70
+ if (mode === "hydrate" &&
71
+ group.filter((binding) => !binding.fresh && binding.node.checked)
72
+ .length > 1)
73
+ error("ZR_MODEL_CONFLICT", group[0].path, "multiple checked radios");
74
+ }
75
+ else if (mode === "hydrate" &&
76
+ group
77
+ .filter((binding) => !binding.fresh)
78
+ .some((binding, _, adopted) => !Object.is(binding.read(), adopted[0].read())))
79
+ error("ZR_MODEL_CONFLICT", group[0].path, "shared model controls disagree");
80
+ }
81
+ },
82
+ members(binding) {
83
+ return binding.live
84
+ ? [...(controlOwners.get(container.ownerDocument) ?? [])]
85
+ .filter((entry) => entry.root === container &&
86
+ entry.scope.active &&
87
+ entry.binding.model === binding.model)
88
+ .map((entry) => entry.binding)
89
+ : binding.group;
90
+ },
91
+ reconcile(groups, mode) {
92
+ if (mode === "hydrate")
93
+ batch(() => {
94
+ for (const [model, group] of groups) {
95
+ const adopted = group.filter((binding) => !binding.fresh);
96
+ if (adopted.length)
97
+ model.value =
98
+ adopted[0].kind === "radio"
99
+ ? (adopted.find((binding) => binding.read() !== null)?.read() ??
100
+ (group.some((binding) => binding.fresh && binding.node.value === model.value)
101
+ ? model.value
102
+ : null))
103
+ : adopted[0].read();
104
+ for (const binding of group)
105
+ if (binding.fresh)
106
+ binding.write(model.value);
107
+ }
108
+ });
109
+ else
110
+ for (const [model, group] of groups)
111
+ for (const binding of group)
112
+ binding.write(model.value);
113
+ },
114
+ };
115
+ }
116
+ //# sourceMappingURL=form-recovery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-recovery.js","sourceRoot":"","sources":["../../src/zudo-react/form-recovery.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FormRecovery, FormHost } from \"./forms.js\";\nimport type { RuntimeScope } from \"./scope.js\";\nfunction within(scope: RuntimeScope, parents: readonly RuntimeScope[]): boolean {\n for (let current: RuntimeScope | undefined = scope; current; current = current.parent)\n if (parents.includes(current)) return true;\n return false;\n}\n\n// Optional policy uses the owning renderer's ledger and errors across package copies.\nexport function createFormRecovery(container: Element, host: FormHost): FormRecovery {\n const { controlOwners, error, batch } = host;\n return {\n prepare(plans, map, mode, replacing, parent) {\n const installed = [...(controlOwners.get(container.ownerDocument) ?? [])].filter(\n (entry) =>\n entry.root === container && entry.scope.active && !within(entry.scope, replacing),\n );\n // Fresh boundary controls still sit in the provisional tree during preflight.\n // Resolve their ancestor form through the hydration map for group identity.\n const plannedForms = new Map<string, Node>();\n for (const plan of plans) {\n const node = map.get(plan.element) as Element;\n if (node.localName === \"form\" && node.id && !plannedForms.has(node.id))\n plannedForms.set(node.id, node);\n }\n const form = (node: HTMLInputElement): Node | null => {\n const id = node.getAttribute(\"form\");\n if (id !== null) {\n const target = plannedForms.get(id) ?? container.ownerDocument.getElementById(id);\n return target instanceof Element && target.localName === \"form\" ? target : null;\n }\n const ancestor = node.closest(\"form\");\n if (ancestor) return map.get(ancestor) ?? ancestor;\n return parent instanceof Element\n ? parent.localName === \"form\"\n ? parent\n : parent.closest(\"form\")\n : null;\n };\n return { installed, form };\n },\n validate(groupByModel, mode, { installed, form }) {\n for (const [model, group] of groupByModel) {\n for (const binding of group) {\n const radio = binding.node as HTMLInputElement;\n if (\n binding.kind === \"radio\" &&\n installed.some(\n (entry) =>\n entry.binding.kind === \"radio\" &&\n entry.name === radio.name &&\n entry.form === form(radio) &&\n entry.binding.model !== model,\n )\n )\n error(\"ZR_MODEL_CONFLICT\", binding.path, \"same-name radios use different models\");\n }\n const related = installed\n .filter((entry) => entry.binding.model === model)\n .map((entry) => entry.binding);\n const complete = [...group, ...related];\n for (const binding of group) binding.group = complete;\n const radios = complete.every((binding) => binding.kind === \"radio\");\n if (complete.some((binding) => binding.kind === \"radio\") && !radios)\n error(\"ZR_MODEL_CONFLICT\", group[0]!.path, \"radio and non-radio share model\");\n if (radios) {\n const nodes = complete.map((binding) => binding.node as HTMLInputElement);\n const names = new Set(\n nodes.map((node) => `${node.name}\\u0000${node.getAttribute(\"form\") ?? \"\"}`),\n );\n const optionValues = nodes.map((node) => node.value);\n if (\n names.size !== 1 ||\n new Set(nodes.map(form)).size !== 1 ||\n new Set(optionValues).size !== optionValues.length\n )\n error(\"ZR_MODEL_CONFLICT\", group[0]!.path, \"radio group membership or duplicate value\");\n if (\n (mode === \"mount\" || group.every((binding) => binding.fresh)) &&\n model.value !== null &&\n !optionValues.includes(model.value as string)\n )\n error(\"ZR_MODEL_VALUE\", group[0]!.path, \"radio model has no option\");\n if (\n mode === \"hydrate\" &&\n group.filter((binding) => !binding.fresh && (binding.node as HTMLInputElement).checked)\n .length > 1\n )\n error(\"ZR_MODEL_CONFLICT\", group[0]!.path, \"multiple checked radios\");\n } else if (\n mode === \"hydrate\" &&\n group\n .filter((binding) => !binding.fresh)\n .some((binding, _, adopted) => !Object.is(binding.read(), adopted[0]!.read()))\n )\n error(\"ZR_MODEL_CONFLICT\", group[0]!.path, \"shared model controls disagree\");\n }\n },\n members(binding) {\n return binding.live\n ? [...(controlOwners.get(container.ownerDocument) ?? [])]\n .filter(\n (entry) =>\n entry.root === container &&\n entry.scope.active &&\n entry.binding.model === binding.model,\n )\n .map((entry) => entry.binding)\n : binding.group;\n },\n reconcile(groups, mode) {\n if (mode === \"hydrate\")\n batch(() => {\n for (const [model, group] of groups) {\n const adopted = group.filter((binding) => !binding.fresh);\n if (adopted.length)\n model.value =\n adopted[0]!.kind === \"radio\"\n ? (adopted.find((binding) => binding.read() !== null)?.read() ??\n (group.some(\n (binding) =>\n binding.fresh && (binding.node as HTMLInputElement).value === model.value,\n )\n ? model.value\n : null))\n : adopted[0]!.read();\n for (const binding of group) if (binding.fresh) binding.write(model.value);\n }\n });\n else\n for (const [model, group] of groups)\n for (const binding of group) binding.write(model.value);\n },\n };\n}\n"]}
@@ -1,16 +1,28 @@
1
+ import { batch } from "./scheduler.js";
1
2
  import type { Signal } from "./reactive-types.js";
2
3
  import type { RuntimeScope } from "./scope.js";
3
4
  import { type RootOptions } from "./root.js";
5
+ declare const controlOwners: WeakMap<Document, Set<ControlOwner>>;
6
+ export interface ControlOwner {
7
+ root: Element;
8
+ name: string;
9
+ form: HTMLFormElement | null;
10
+ binding: Binding;
11
+ scope: RuntimeScope;
12
+ }
4
13
  type Control = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
5
- type Model = Signal<string | boolean | null>;
14
+ export type Model = Signal<string | boolean | null>;
6
15
  export interface FormPlan {
7
16
  element: Element;
8
17
  props: Readonly<Record<string, unknown>>;
9
18
  path: string;
10
19
  }
11
- interface Binding {
20
+ export interface Binding {
12
21
  node: Control;
13
22
  model: Model;
23
+ group?: Binding[];
24
+ fresh: boolean;
25
+ live: boolean;
14
26
  kind: "text" | "textarea" | "checkbox" | "select" | "radio";
15
27
  read(): string | boolean | null;
16
28
  write(value: string | boolean | null): void;
@@ -23,7 +35,29 @@ export declare class FormError extends TypeError {
23
35
  readonly detail: string;
24
36
  constructor(code: string, path: string, detail: string);
25
37
  }
26
- export declare function prepareForms(plans: FormPlan[], map: Map<Node, Node>, mode: "hydrate" | "mount", container: Element): Binding[];
27
- export declare function reconcileForms(bindings: Binding[], mode: "hydrate" | "mount"): void;
38
+ declare function error(code: string, path: string, detail: string): never;
39
+ export declare function prepareForms(plans: FormPlan[], map: Map<Node, Node>, mode: "hydrate" | "mount", container: Element, replacing?: readonly RuntimeScope[], parent?: Node | null): Binding[];
40
+ export declare function reconcileForms(bindings: Binding[], mode: "hydrate" | "mount", container: Element): void;
28
41
  export declare function activateForms(bindings: Binding[], scope: RuntimeScope, cleanups: Array<() => void>, options: RootOptions, container: Element): void;
42
+ export interface FormState {
43
+ form(node: HTMLInputElement): Node | null;
44
+ installed: ControlOwner[];
45
+ }
46
+ export interface FormRecovery {
47
+ prepare(plans: FormPlan[], map: Map<Node, Node>, mode: "hydrate" | "mount", replacing: readonly RuntimeScope[], parent?: Node | null): FormState;
48
+ validate(groups: Map<Model, Binding[]>, mode: "hydrate" | "mount", state: FormState): void;
49
+ members(binding: Binding): Binding[] | undefined;
50
+ reconcile(groups: Map<Model, Binding[]>, mode: "hydrate" | "mount"): void;
51
+ }
52
+ export interface FormHost {
53
+ controlOwners: typeof controlOwners;
54
+ error: typeof error;
55
+ batch: typeof batch;
56
+ }
57
+ export declare const formHost: {
58
+ controlOwners: WeakMap<Document, Set<ControlOwner>>;
59
+ error: typeof error;
60
+ batch: typeof batch;
61
+ recoveries: WeakMap<Element, FormRecovery>;
62
+ };
29
63
  export {};
@@ -3,38 +3,31 @@ import { isReactive } from "./dom-bindings.js";
3
3
  import { subscribe } from "./reactive.js";
4
4
  import { diagnostic, report, rootPath } from "./root.js";
5
5
  const compositionKey = Symbol.for("@takazudo/zfb/zudo-react/composition-v1");
6
- const radioOwners = new WeakMap();
6
+ // Capture every live model before a first boundary can appear in a later render.
7
+ const controlOwners = new WeakMap();
7
8
  export class FormError extends TypeError {
8
- code;
9
- path;
10
- detail;
11
9
  constructor(code, path, detail) {
12
10
  super(`${code}: ${detail} at ${path}`);
13
- this.code = code;
14
- this.path = path;
15
- this.detail = detail;
11
+ Object.assign(this, { code, path, detail });
16
12
  }
17
13
  }
18
14
  function error(code, path, detail) {
19
15
  throw new FormError(code, path, detail);
20
16
  }
21
17
  function writable(value, path) {
22
- if (!value ||
23
- typeof value !== "object" ||
24
- !("$$zudoReactive" in value) ||
25
- value.$$zudoReactive !== "zudo-react.reactive.v1" ||
26
- !("$$zudoWritable" in value) ||
27
- value.$$zudoWritable !== "zudo-react.writable.v1")
28
- error("ZR_MODEL_READONLY", path, "model requires a writable signal");
29
- let proto = value;
30
- while (proto) {
31
- const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
32
- if (descriptor) {
33
- if (typeof descriptor.set !== "function")
34
- error("ZR_MODEL_READONLY", path, "model requires a writable signal");
35
- return value;
18
+ if (isReactive(value) &&
19
+ "$$zudoWritable" in value &&
20
+ value.$$zudoWritable === "zudo-react.writable.v1") {
21
+ let proto = value;
22
+ while (proto) {
23
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
24
+ if (descriptor) {
25
+ if (typeof descriptor.set === "function")
26
+ return value;
27
+ break;
28
+ }
29
+ proto = Object.getPrototypeOf(proto);
36
30
  }
37
- proto = Object.getPrototypeOf(proto);
38
31
  }
39
32
  return error("ZR_MODEL_READONLY", path, "model requires a writable signal");
40
33
  }
@@ -52,95 +45,50 @@ function kind(node, props) {
52
45
  return "radio";
53
46
  return ["text", "search", "email", "url", "tel", "password"].includes(type) ? "text" : null;
54
47
  }
55
- // Text input: read/write string .value; listen to input.
56
- function textAdapter(node, path) {
57
- return {
58
- event: "input",
59
- read: () => node.value,
60
- write: (value) => {
61
- if (typeof value !== "string")
62
- error("ZR_MODEL_VALUE", path, "text requires string");
63
- if (node.value !== value)
64
- node.value = value;
65
- },
66
- };
67
- }
68
- // Textarea: read/write string .value; listen to input.
69
- function textareaAdapter(node, path) {
70
- return {
71
- event: "input",
72
- read: () => node.value,
73
- write: (value) => {
74
- if (typeof value !== "string")
75
- error("ZR_MODEL_VALUE", path, "textarea requires string");
76
- if (node.value !== value)
77
- node.value = value;
78
- },
79
- };
80
- }
81
- // Checkbox: read/write boolean .checked; listen to change.
82
- function checkboxAdapter(node, path) {
48
+ // All adapters preserve native input/change events and avoid redundant writes.
49
+ function adapterFor(kind, control, path) {
50
+ const node = control;
51
+ const radio = kind === "radio";
52
+ const checkbox = kind === "checkbox";
53
+ const property = checkbox || radio ? "checked" : "value";
83
54
  return {
84
- event: "change",
85
- read: () => node.checked,
55
+ event: kind === "text" || kind === "textarea" ? "input" : "change",
56
+ read: () => (radio ? (node.checked ? node.value : null) : node[property]),
86
57
  write: (value) => {
87
- if (typeof value !== "boolean")
88
- error("ZR_MODEL_VALUE", path, "checkbox requires boolean");
89
- if (node.checked !== value)
90
- node.checked = value;
91
- },
92
- };
93
- }
94
- // Single select: read/write string .value after options exist; listen to change.
95
- function selectAdapter(node, path) {
96
- return {
97
- event: "change",
98
- read: () => node.value,
99
- write: (value) => {
100
- if (typeof value !== "string")
101
- error("ZR_MODEL_VALUE", path, "select requires string");
102
- if (!values(node).includes(value) && !(value === "" && node.selectedIndex === -1))
103
- error("ZR_MODEL_VALUE", path, `select has no option ${value}`);
104
- if (node.value !== value)
105
- node.value = value;
106
- },
107
- };
108
- }
109
- // Radio group: read selected option or null; write checked; listen to change.
110
- function radioAdapter(node) {
111
- return {
112
- event: "change",
113
- read: () => (node.checked ? node.value : null),
114
- write: (value) => {
115
- const checked = value === node.value;
116
- if (node.checked !== checked)
117
- node.checked = checked;
58
+ if (!radio) {
59
+ if (typeof value !== (checkbox ? "boolean" : "string"))
60
+ error("ZR_MODEL_VALUE", path, `${kind} requires ${checkbox ? "boolean" : "string"}`);
61
+ if (kind === "select" &&
62
+ !values(control).includes(value) &&
63
+ !(value === "" && control.selectedIndex === -1))
64
+ error("ZR_MODEL_VALUE", path, `select has no option ${value}`);
65
+ }
66
+ const next = radio ? value === node.value : value;
67
+ if (node[property] !== next)
68
+ node[property] = next;
118
69
  },
119
70
  };
120
71
  }
121
- function adapterFor(kind, node, path) {
122
- switch (kind) {
123
- case "text":
124
- return textAdapter(node, path);
125
- case "textarea":
126
- return textareaAdapter(node, path);
127
- case "checkbox":
128
- return checkboxAdapter(node, path);
129
- case "select":
130
- return selectAdapter(node, path);
131
- case "radio":
132
- return radioAdapter(node);
133
- }
134
- }
135
72
  function values(node) {
136
73
  return [...node.options].map((option) => option.value);
137
74
  }
138
75
  function optionValuesFor(group) {
139
76
  return group.map((binding) => binding.node.value);
140
77
  }
141
- export function prepareForms(plans, map, mode, container) {
78
+ function groupBindings(bindings) {
79
+ const groups = new Map();
80
+ for (const binding of bindings) {
81
+ const group = groups.get(binding.model) ?? [];
82
+ group.push(binding);
83
+ groups.set(binding.model, group);
84
+ }
85
+ return groups;
86
+ }
87
+ export function prepareForms(plans, map, mode, container, replacing = [], parent) {
88
+ const recovery = formRecoveries.get(container);
89
+ const state = recovery?.prepare(plans, map, mode, replacing, parent);
90
+ const form = state?.form ?? ((node) => node.form);
142
91
  const bindings = [];
143
- const groupByModel = new Map();
144
92
  const namedRadios = new Map();
145
93
  for (const plan of plans) {
146
94
  const { props, path } = plan;
@@ -193,8 +141,11 @@ export function prepareForms(plans, map, mode, container) {
193
141
  const radio = node;
194
142
  if (!radio.name || typeof props.value !== "string")
195
143
  error("ZR_MODEL_UNSUPPORTED", path, "radio requires static name and value");
196
- const owners = radioOwners.get(node.ownerDocument);
197
- if ([...(owners ?? [])].some((entry) => entry.root !== container && entry.name === radio.name && entry.form === radio.form))
144
+ const owners = controlOwners.get(node.ownerDocument);
145
+ if ([...(owners ?? [])].some((entry) => entry.root !== container &&
146
+ entry.binding.kind === "radio" &&
147
+ entry.name === radio.name &&
148
+ entry.form === form(radio)))
198
149
  error("ZR_MODEL_CONFLICT", path, "same-name radio owned by another root");
199
150
  const key = `${radio.name}\u0000${radio.getAttribute("form") ?? ""}`;
200
151
  const prior = namedRadios.get(key);
@@ -205,41 +156,50 @@ export function prepareForms(plans, map, mode, container) {
205
156
  const binding = {
206
157
  node: node,
207
158
  model,
159
+ fresh: mode === "hydrate" && node === plan.element,
160
+ live: false,
208
161
  kind: controlKind,
209
162
  path,
210
163
  ...adapterFor(controlKind, node, path),
211
164
  };
212
165
  bindings.push(binding);
213
- const group = groupByModel.get(model) ?? [];
214
- group.push(binding);
215
- groupByModel.set(model, group);
216
166
  }
217
167
  }
218
- for (const [model, group] of groupByModel) {
219
- const radios = group.every((binding) => binding.kind === "radio");
220
- if (group.some((binding) => binding.kind === "radio") && !radios)
221
- error("ZR_MODEL_CONFLICT", group[0].path, "radio and non-radio share model");
222
- if (radios) {
223
- const nodes = group.map((binding) => binding.node);
224
- const names = new Set(nodes.map((node) => `${node.name}\u0000${node.getAttribute("form") ?? ""}`));
225
- const optionValues = nodes.map((node) => node.value);
226
- if (names.size !== 1 ||
227
- new Set(nodes.map((node) => node.form)).size !== 1 ||
228
- new Set(optionValues).size !== optionValues.length)
229
- error("ZR_MODEL_CONFLICT", group[0].path, "radio group membership or duplicate value");
230
- if (mode === "mount" && model.value !== null && !optionValues.includes(model.value))
231
- error("ZR_MODEL_VALUE", group[0].path, "radio model has no option");
232
- if (mode === "hydrate" && nodes.filter((node) => node.checked).length > 1)
233
- error("ZR_MODEL_CONFLICT", group[0].path, "multiple checked radios");
168
+ const groupByModel = groupBindings(bindings);
169
+ if (recovery)
170
+ recovery.validate(groupByModel, mode, state);
171
+ else {
172
+ for (const [model, group] of groupByModel) {
173
+ const radios = group.every((binding) => binding.kind === "radio");
174
+ if (group.some((binding) => binding.kind === "radio") && !radios)
175
+ error("ZR_MODEL_CONFLICT", group[0].path, "radio and non-radio share model");
176
+ if (radios) {
177
+ const nodes = group.map((binding) => binding.node);
178
+ const names = new Set(nodes.map((node) => `${node.name}\u0000${node.getAttribute("form") ?? ""}`));
179
+ const optionValues = nodes.map((node) => node.value);
180
+ if (names.size !== 1 ||
181
+ new Set(nodes.map((node) => node.form)).size !== 1 ||
182
+ new Set(optionValues).size !== optionValues.length)
183
+ error("ZR_MODEL_CONFLICT", group[0].path, "radio group membership or duplicate value");
184
+ if (mode === "mount" &&
185
+ model.value !== null &&
186
+ !optionValues.includes(model.value))
187
+ error("ZR_MODEL_VALUE", group[0].path, "radio model has no option");
188
+ if (mode === "hydrate" && nodes.filter((node) => node.checked).length > 1)
189
+ error("ZR_MODEL_CONFLICT", group[0].path, "multiple checked radios");
190
+ }
191
+ else if (mode === "hydrate" &&
192
+ group.some((binding) => !Object.is(binding.read(), group[0].read())))
193
+ error("ZR_MODEL_CONFLICT", group[0].path, "shared model controls disagree");
234
194
  }
235
- else if (mode === "hydrate" &&
236
- group.some((binding) => !Object.is(binding.read(), group[0].read())))
237
- error("ZR_MODEL_CONFLICT", group[0].path, "shared model controls disagree");
238
- if (radios) {
195
+ }
196
+ for (const group of groupByModel.values()) {
197
+ if (group[0].kind === "radio") {
239
198
  for (const binding of group) {
240
199
  const originalWrite = binding.write;
241
200
  binding.write = (value) => {
242
- if (value !== null && !optionValuesFor(group).includes(value))
201
+ if (value !== null &&
202
+ !optionValuesFor(formRecoveries.get(container)?.members(binding) ?? group).includes(value))
243
203
  error("ZR_MODEL_VALUE", binding.path, "radio model has no option");
244
204
  originalWrite(value);
245
205
  };
@@ -248,40 +208,51 @@ export function prepareForms(plans, map, mode, container) {
248
208
  }
249
209
  return bindings;
250
210
  }
251
- export function reconcileForms(bindings, mode) {
252
- const groups = new Map();
253
- for (const binding of bindings)
254
- groups.set(binding.model, [...(groups.get(binding.model) ?? []), binding]);
211
+ export function reconcileForms(bindings, mode, container) {
212
+ const groups = groupBindings(bindings);
213
+ const recovery = formRecoveries.get(container);
214
+ if (recovery)
215
+ return recovery.reconcile(groups, mode);
255
216
  if (mode === "hydrate")
256
- batch(() => {
257
- for (const [model, group] of groups) {
258
- model.value =
259
- group[0].kind === "radio"
260
- ? (group.find((binding) => binding.read() !== null)?.read() ?? null)
261
- : group[0].read();
262
- }
263
- });
217
+ readModels(groups);
264
218
  else
265
219
  for (const [model, group] of groups)
266
220
  for (const binding of group)
267
221
  binding.write(model.value);
268
222
  }
223
+ function readModels(groups) {
224
+ batch(() => {
225
+ for (const [model, group] of groups)
226
+ model.value =
227
+ group[0].kind === "radio"
228
+ ? (group.find((binding) => binding.read() !== null)?.read() ?? null)
229
+ : group[0].read();
230
+ });
231
+ }
269
232
  export function activateForms(bindings, scope, cleanups, options, container) {
270
233
  const byForm = new Map();
271
- const owned = radioOwners.get(container.ownerDocument) ??
272
- new Set();
273
- radioOwners.set(container.ownerDocument, owned);
234
+ const owned = controlOwners.get(container.ownerDocument) ?? new Set();
235
+ controlOwners.set(container.ownerDocument, owned);
274
236
  const registrations = [];
275
- for (const binding of bindings)
276
- if (binding.kind === "radio") {
277
- const node = binding.node;
278
- const registration = { root: container, name: node.name, form: node.form };
279
- registrations.push(registration);
280
- owned.add(registration);
281
- }
237
+ for (const binding of bindings) {
238
+ const node = binding.node;
239
+ const registration = {
240
+ root: container,
241
+ name: node.name,
242
+ form: (node.hasAttribute("form")
243
+ ? node.ownerDocument.getElementById(node.getAttribute("form"))
244
+ : node.closest("form")),
245
+ binding,
246
+ scope,
247
+ };
248
+ registrations.push(registration);
249
+ owned.add(registration);
250
+ }
282
251
  cleanups.push(() => {
283
- for (const registration of registrations)
252
+ for (const registration of registrations) {
253
+ registration.binding.live = false;
284
254
  owned.delete(registration);
255
+ }
285
256
  });
286
257
  for (const binding of bindings) {
287
258
  const { node, model } = binding;
@@ -309,20 +280,16 @@ export function activateForms(bindings, scope, cleanups, options, container) {
309
280
  unknown = false;
310
281
  update();
311
282
  };
312
- node.addEventListener(binding.event, onInput);
313
- if (textual) {
314
- node.addEventListener("compositionstart", onStart);
315
- node.addEventListener("compositionend", onFinish);
316
- node.addEventListener("blur", onFinish);
317
- }
283
+ const listeners = [[binding.event, onInput]];
284
+ if (textual)
285
+ listeners.push(["compositionstart", onStart], ["compositionend", onFinish], ["blur", onFinish]);
286
+ for (const [name, handler] of listeners)
287
+ node.addEventListener(name, handler);
318
288
  cleanups.push(() => {
319
- node.removeEventListener(binding.event, onInput);
320
- if (textual) {
321
- node.removeEventListener("compositionstart", onStart);
322
- node.removeEventListener("compositionend", onFinish);
323
- node.removeEventListener("blur", onFinish);
324
- }
289
+ for (const [name, handler] of listeners)
290
+ node.removeEventListener(name, handler);
325
291
  });
292
+ const reportUpdate = (code, actual) => report(options, diagnostic(options, code, "update", `${rootPath(container)}${binding.path}`, "valid model update", actual));
326
293
  const subscription = subscribe(() => {
327
294
  const value = model.value;
328
295
  if (!composing && !unknown) {
@@ -332,14 +299,15 @@ export function activateForms(bindings, scope, cleanups, options, container) {
332
299
  catch (failure) {
333
300
  if (!(failure instanceof FormError))
334
301
  throw failure;
335
- report(options, diagnostic(options, failure.code, "update", `${rootPath(container)}${binding.path}`, "valid model update", failure.detail));
302
+ reportUpdate(failure.code, failure.detail);
336
303
  }
337
304
  }
338
305
  }, {
339
306
  active: () => scope.active,
340
- report: (code, actual) => report(options, diagnostic(options, code, "update", `${rootPath(container)}${binding.path}`, "valid model update", actual)),
307
+ report: reportUpdate,
341
308
  });
342
309
  subscription.run();
310
+ binding.live = true;
343
311
  cleanups.push(() => subscription.dispose());
344
312
  const form = node.form;
345
313
  if (form)
@@ -350,16 +318,17 @@ export function activateForms(bindings, scope, cleanups, options, container) {
350
318
  const onReset = (event) => queueMicrotask(() => {
351
319
  if (!active || event.defaultPrevented)
352
320
  return;
353
- batch(() => {
354
- const groups = new Map();
355
- for (const binding of members)
356
- groups.set(binding.model, [...(groups.get(binding.model) ?? []), binding]);
357
- for (const [model, group] of groups)
358
- model.value =
359
- group[0].kind === "radio"
360
- ? (group.find((binding) => binding.read() !== null)?.read() ?? null)
361
- : group[0].read();
362
- });
321
+ // A boundary owns only part of a native radio group. Read all current
322
+ // members after the browser reset, not just this scope's unchecked part.
323
+ // Keep ownership-local listeners so disposing a replaced attempt still
324
+ // cancels its queued reset work.
325
+ const recovery = formRecoveries.get(container);
326
+ const current = recovery
327
+ ? [
328
+ ...new Set(members.flatMap((binding) => recovery.members(binding) ?? [binding])),
329
+ ].filter((binding) => binding.node.form === form)
330
+ : members;
331
+ readModels(groupBindings(current));
363
332
  });
364
333
  form.addEventListener("reset", onReset);
365
334
  cleanups.push(() => {
@@ -368,4 +337,6 @@ export function activateForms(bindings, scope, cleanups, options, container) {
368
337
  });
369
338
  }
370
339
  }
340
+ const formRecoveries = new WeakMap();
341
+ export const formHost = { controlOwners, error, batch, recoveries: formRecoveries };
371
342
  //# sourceMappingURL=forms.js.map