@riducms/admin 0.2.1 → 0.2.2

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.
package/package.json CHANGED
@@ -9,11 +9,11 @@
9
9
  "@hvniel/svelte-router": "^0.0.6",
10
10
  "@iconify/json": "^2.2.509",
11
11
  "@internationalized/date": "^3.12.3",
12
- "@riducms/plugin": "0.2.1",
13
- "@riducms/protocol": "0.2.1",
14
- "@riducms/sdk": "0.2.1",
15
- "@riducms/translations": "0.2.1",
16
- "@riducms/ui": "0.2.1",
12
+ "@riducms/plugin": "0.2.2",
13
+ "@riducms/protocol": "0.2.2",
14
+ "@riducms/sdk": "0.2.2",
15
+ "@riducms/translations": "0.2.2",
16
+ "@riducms/ui": "0.2.2",
17
17
  "@types/prismjs": "^1.26.5",
18
18
  "bits-ui": "^2.18.1",
19
19
  "prismjs": "^1.30.0",
@@ -22,7 +22,7 @@
22
22
  "description": "Framework-owned Svelte 5 authoring application embedded by Ridu servers.",
23
23
  "devDependencies": {
24
24
  "@hvniel/lexical-svelte": "^0.1.1",
25
- "@riducms/build": "0.2.1",
25
+ "@riducms/build": "0.2.2",
26
26
  "svelte": "^5.56.8"
27
27
  },
28
28
  "exports": {
@@ -60,5 +60,5 @@
60
60
  "test:unit": "bun test tests"
61
61
  },
62
62
  "type": "module",
63
- "version": "0.2.1"
63
+ "version": "0.2.2"
64
64
  }
package/src/app.svelte CHANGED
@@ -25,16 +25,7 @@
25
25
  let { clientFactory, adminBasePath = "/admin", adminConfig = {} }: Props = $props();
26
26
  // The runtime owns the client and plugin registry selected when this admin instance is mounted.
27
27
  // svelte-ignore state_referenced_locally
28
- const runtime = setAdminRuntime(
29
- new AdminRuntime(
30
- clientFactory(),
31
- adminConfig.plugins,
32
- adminConfig.languages,
33
- undefined,
34
- adminConfig.fields,
35
- adminConfig
36
- )
37
- );
28
+ const runtime = setAdminRuntime(new AdminRuntime(clientFactory(), adminConfig));
38
29
  setAdminI18n(runtime.i18n);
39
30
  const notifications = setNotificationCenter(new NotificationCenter());
40
31
  const development = import.meta.hot !== undefined;
@@ -13,6 +13,7 @@ type AdminCollection = CollectionContract & {
13
13
  auth: true;
14
14
  upload: true;
15
15
  versions: true;
16
+ drafts: true;
16
17
  output: AdminDocument;
17
18
  create: Record<string, unknown>;
18
19
  update: Record<string, unknown>;
@@ -24,6 +25,7 @@ type AdminCollection = CollectionContract & {
24
25
 
25
26
  type AdminGlobal = {
26
27
  versions: true;
28
+ drafts: true;
27
29
  output: AdminDocument;
28
30
  update: Record<string, unknown>;
29
31
  select: Record<string, boolean>;
@@ -5,7 +5,7 @@ import type {
5
5
  EmbeddedSchemaFormScope,
6
6
  EmbeddedSchemaVariantScope,
7
7
  } from "@riducms/plugin";
8
- import type { SchemaField } from "@riducms/protocol";
8
+ import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
9
9
  import { FormController } from "@admin/core/forms/form-controller.svelte";
10
10
  import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
11
11
  import { cloneFormValue, initialFormValues } from "@admin/core/forms/form-schema";
@@ -19,6 +19,8 @@ export interface HostedSchemaDraft {
19
19
  draft: EmbeddedSchemaDraft;
20
20
  form: FormController;
21
21
  fields: SchemaField[];
22
+ block: SchemaBlockType;
23
+ treeKey: string;
22
24
  }
23
25
 
24
26
  export function embeddedVariant(field: SchemaField, scope: EmbeddedSchemaVariantScope) {
@@ -331,5 +333,5 @@ export function createEmbeddedSchemaDraft(
331
333
  form.disposeBindings();
332
334
  },
333
335
  };
334
- return { draft, form, fields };
336
+ return { draft, form, fields, block, treeKey: scope.treeKey };
335
337
  }
@@ -0,0 +1,155 @@
1
+ import { cloneSchemaField, type SchemaField } from "@riducms/protocol";
2
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
3
+ import { captureFieldOccurrence, type FieldOccurrence } from "@admin/core/forms/field-occurrence";
4
+ import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
5
+ import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
6
+
7
+ /** Private owner shared by local and paired adapters. Revocation is synchronous and permanent. */
8
+ export class FieldBindingLifetime {
9
+ #revoked = false;
10
+ #source: SchemaField;
11
+ #snapshot: SchemaField;
12
+ #path: string;
13
+ #occurrence: FieldOccurrence;
14
+ #epoch: number;
15
+ #version: unknown;
16
+ #resource: string;
17
+ #locale: string | undefined;
18
+ #stopPath: () => void;
19
+ #stopObserve: () => void;
20
+ #stopLifetime: () => void;
21
+ #cleanup = new Set<() => void>();
22
+
23
+ constructor(
24
+ private form: FormController,
25
+ private getSchema: () => SchemaField,
26
+ private schemaVersion: () => unknown = () => form.editorEpoch
27
+ ) {
28
+ this.#source = getSchema();
29
+ this.#snapshot = cloneSchemaField(this.#source);
30
+ this.#path = this.#source.path;
31
+ this.#occurrence = captureFieldOccurrence(form, this.#path);
32
+ this.#epoch = form.editorEpoch;
33
+ this.#version = schemaVersion();
34
+ this.#resource = JSON.stringify(form.resource);
35
+ this.#locale = form.contentLocale;
36
+ this.#stopPath = form.register(this.#path);
37
+ this.#stopObserve = form.observe(this.#path.split(".")[0]!, () => this.#resolve());
38
+ this.#stopLifetime = form.registerEditorLifetime(this.destroy);
39
+ }
40
+
41
+ get stale() {
42
+ this.#resolve();
43
+ return this.#revoked;
44
+ }
45
+ get path() {
46
+ this.#resolve();
47
+ return this.#path;
48
+ }
49
+ /** A revoked local host can render cached metadata once before its subtree unmounts. */
50
+ get schema(): SchemaField {
51
+ this.#resolve();
52
+ return { ...cloneSchemaField(this.#revoked ? this.#snapshot : this.#source), path: this.#path };
53
+ }
54
+ get visible() {
55
+ if (this.stale) return false;
56
+ return this.#selections().every(({ schema, resolve }) => {
57
+ const path = resolve();
58
+ return path !== undefined && this.#visible(schema, path);
59
+ });
60
+ }
61
+ /** Permission/schema restrictions exclude the temporary pending-save lock. */
62
+ get schemaReadOnly() {
63
+ if (this.stale) return true;
64
+ return this.#selections().some(({ schema, resolve }) => {
65
+ const path = resolve();
66
+ return (
67
+ path === undefined ||
68
+ schema.admin.readOnly === true ||
69
+ !this.#visible(schema, path) ||
70
+ !this.form.canWrite(path, fieldAccessPath(schema))
71
+ );
72
+ });
73
+ }
74
+ get readOnly() {
75
+ return this.schemaReadOnly || this.form.editingBlocked;
76
+ }
77
+ #selections() {
78
+ return [...this.#occurrence.ancestors, { schema: this.#source, resolve: () => this.#path }];
79
+ }
80
+ #visible(schema: SchemaField, path: string) {
81
+ return (
82
+ schema.admin.hidden !== true &&
83
+ this.form.canRead(path, fieldAccessPath(schema)) &&
84
+ (schema.admin.condition === undefined ||
85
+ evaluateFieldCondition(schema.admin.condition, path, (other) => this.form.get(other)))
86
+ );
87
+ }
88
+ onDestroy(cleanup: () => void) {
89
+ if (this.stale) {
90
+ cleanup();
91
+ return () => {};
92
+ }
93
+ this.#cleanup.add(cleanup);
94
+ return () => {
95
+ this.#cleanup.delete(cleanup);
96
+ };
97
+ }
98
+ destroy = () => {
99
+ if (this.#revoked) return;
100
+ this.#revoked = true;
101
+ // Never call component-owned getters during or after revocation.
102
+ this.#snapshot = cloneSchemaField(this.#source);
103
+ this.#stopPath();
104
+ this.#stopObserve();
105
+ this.#stopLifetime();
106
+ const callbacks = [...this.#cleanup];
107
+ this.#cleanup.clear();
108
+ // Dispose every child even if one cleanup fails.
109
+ const failures: unknown[] = [];
110
+ for (const cleanup of callbacks) {
111
+ try {
112
+ cleanup();
113
+ } catch (error) {
114
+ failures.push(error);
115
+ }
116
+ }
117
+ if (failures.length) throw new AggregateError(failures, "Field binding cleanup failed.");
118
+ };
119
+ #resolve() {
120
+ if (this.#revoked) return;
121
+ if (
122
+ this.form.editorEpoch !== this.#epoch ||
123
+ this.form.contentLocale !== this.#locale ||
124
+ JSON.stringify(this.form.resource) !== this.#resource ||
125
+ !this.form.editorScopeActive
126
+ ) {
127
+ this.destroy();
128
+ return;
129
+ }
130
+ if (this.#revoked) return;
131
+ const version = this.schemaVersion();
132
+ if (this.#revoked) return;
133
+ if (version !== this.#version) {
134
+ this.destroy();
135
+ return;
136
+ }
137
+ const schema = this.getSchema();
138
+ if (this.#revoked) return;
139
+ if (schema.id !== this.#source.id) {
140
+ this.destroy();
141
+ return;
142
+ }
143
+ this.#source = schema;
144
+ const path = this.#occurrence.resolve();
145
+ if (path === undefined) {
146
+ this.destroy();
147
+ return;
148
+ }
149
+ if (path !== this.#path) {
150
+ this.#stopPath();
151
+ this.#path = path;
152
+ this.#stopPath = this.form.register(path);
153
+ }
154
+ }
155
+ }
@@ -1,7 +1,8 @@
1
+ import { scalarLiteralValue } from "@admin/core/forms/scalar-literal";
1
2
  import type {
2
3
  SchemaFieldCondition,
3
4
  SchemaFieldConditionPredicate,
4
- SchemaFieldConditionValue,
5
+ SchemaScalarLiteral,
5
6
  } from "@riducms/protocol";
6
7
 
7
8
  export type FieldConditionValueReader = (path: string) => unknown;
@@ -40,7 +41,7 @@ function evaluatePredicate(
40
41
  read: FieldConditionValueReader
41
42
  ) {
42
43
  const actual = read(fieldConditionValuePath(predicate.scope, predicate.path, fieldPath));
43
- const matches = (expected: SchemaFieldConditionValue) => scalarMatches(actual, expected);
44
+ const matches = (expected: SchemaScalarLiteral) => scalarMatches(actual, expected);
44
45
  switch (predicate.operator) {
45
46
  case "equals":
46
47
  return predicate.values[0] !== undefined && matches(predicate.values[0]);
@@ -51,13 +52,7 @@ function evaluatePredicate(
51
52
  }
52
53
  }
53
54
 
54
- function scalarMatches(actual: unknown, expected: SchemaFieldConditionValue) {
55
- switch (expected.type) {
56
- case "string":
57
- return typeof actual === "string" && actual === expected.value;
58
- case "number":
59
- return typeof actual === "number" && actual === Number(expected.value);
60
- case "boolean":
61
- return typeof actual === "boolean" && actual === (expected.value === "true");
62
- }
55
+ function scalarMatches(actual: unknown, expected: SchemaScalarLiteral) {
56
+ const value = scalarLiteralValue(expected);
57
+ return value !== undefined && actual === value;
63
58
  }
@@ -1,4 +1,4 @@
1
- import { cloneSchemaField } from "@riducms/protocol";
1
+ import { FieldBindingLifetime } from "@admin/core/forms/field-binding-lifetime";
2
2
  import type {
3
3
  FieldBinding,
4
4
  FieldEditorProps,
@@ -8,55 +8,32 @@ import type {
8
8
  import type { SchemaField } from "@riducms/protocol";
9
9
  import { fieldControlARIA } from "@riducms/ui";
10
10
  import type { FormController } from "@admin/core/forms/form-controller.svelte";
11
- import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
12
11
  import { cloneFormValue } from "@admin/core/forms/form-schema";
13
- import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
14
- import { captureFieldOccurrence, type FieldOccurrence } from "@admin/core/forms/field-occurrence";
15
12
 
16
13
  /** The host owns this capability until unmount or an invalidating form transition. */
17
14
  export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBinding<Type> {
18
15
  #form: FormController;
19
- #schema: () => SchemaField;
20
- #occurrence: FieldOccurrence;
21
- #path: string;
22
- #stale = false;
23
- #stopPath: () => void;
24
- #stopObserve: () => void;
25
- #stopLifetime: () => void;
26
- #epoch: number;
27
- #resource: string;
28
- #locale: string | undefined;
16
+ #lifetime: FieldBindingLifetime;
29
17
  #type: Type;
30
- #schemaID: string;
31
- #schemaVersion: () => unknown;
32
- #initialSchemaVersion: unknown;
18
+ #apply: ((value: FieldEditorValue<Type> | null) => void) | undefined;
33
19
  readonly form: FieldEditorProps["form"];
34
20
 
35
21
  constructor(
36
22
  form: FormController,
37
23
  schema: () => SchemaField,
38
24
  type: Type,
39
- schemaVersion: () => unknown = () => form.editorEpoch
25
+ schemaVersion: () => unknown = () => form.editorEpoch,
26
+ apply?: (value: FieldEditorValue<Type> | null) => void
40
27
  ) {
41
28
  this.#form = form;
42
- this.#schemaID = schema().id;
43
- this.#schemaVersion = schemaVersion;
44
- this.#initialSchemaVersion = schemaVersion();
45
- this.#schema = schema;
29
+ this.#apply = apply;
46
30
  this.#type = type;
47
- this.#epoch = form.editorEpoch;
48
- this.#resource = JSON.stringify(form.resource);
49
- this.#locale = form.contentLocale;
50
- this.#path = schema().path;
51
- this.#occurrence = captureFieldOccurrence(form, this.#path);
52
- this.#stopPath = form.register(this.#path);
53
- this.#stopObserve = form.observe(this.#path.split(".")[0]!, () => this.#resolve());
54
- this.#stopLifetime = form.registerEditorLifetime(this.destroy);
31
+ this.#lifetime = new FieldBindingLifetime(form, schema, schemaVersion);
55
32
  const binding = this;
56
33
  this.form = {
57
34
  get contentLocale() {
58
35
  binding.assertActive();
59
- return binding.#locale;
36
+ return form.contentLocale;
60
37
  },
61
38
  get resource() {
62
39
  binding.assertActive();
@@ -78,68 +55,39 @@ export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBi
78
55
  }
79
56
 
80
57
  get schema(): SchemaField & { type: Type } {
81
- // A revoked host can render once before its keyed subtree unmounts. Metadata
82
- // remains a detached snapshot; values and writes stay revoked immediately.
83
- this.#resolve();
84
- return {
85
- ...cloneSchemaField(this.#schema()),
86
- type: this.#type,
87
- path: this.#path,
88
- };
58
+ return { ...this.#lifetime.schema, type: this.#type };
89
59
  }
90
60
  get stale() {
91
- this.#resolve();
92
- return this.#stale;
61
+ return this.#lifetime.stale;
93
62
  }
94
63
  get value(): FieldEditorValue<Type> | null | undefined {
95
64
  if (this.stale) return undefined;
96
- const value = this.#form.get(this.#path);
65
+ const value = this.#form.get(this.#lifetime.path);
97
66
  assertEditorValue(this.#type, value);
98
67
  return cloneFormValue(value) as FieldEditorValue<Type> | null | undefined;
99
68
  }
100
69
  get issues() {
101
- return this.stale ? [] : this.#form.issuesFor(this.#path).map((issue) => ({ ...issue }));
70
+ return this.stale
71
+ ? []
72
+ : this.#form.issuesFor(this.#lifetime.path).map((issue) => ({ ...issue }));
102
73
  }
103
74
  get liveValidation() {
104
75
  if (this.stale) return { status: "idle" as const, retry: () => {} };
105
- const feedback = this.#form.liveValidation.forField(this.#path);
76
+ const feedback = this.#form.liveValidation.forField(this.#lifetime.path);
106
77
  return {
107
78
  status: feedback.status,
108
79
  retry: () => {
109
80
  this.assertActive();
110
- if (!this.readOnly) this.#form.liveValidation.flush(this.#path);
81
+ if (!this.readOnly) this.#form.liveValidation.flush(this.#lifetime.path);
111
82
  },
112
83
  };
113
84
  }
114
85
 
86
+ get visible() {
87
+ return this.#lifetime.visible;
88
+ }
115
89
  get readOnly() {
116
- if (this.stale) return true;
117
- if (
118
- this.#occurrence.ancestors.some(({ schema, resolve }) => {
119
- const path = resolve();
120
- return (
121
- path === undefined ||
122
- schema.admin.readOnly === true ||
123
- schema.admin.hidden === true ||
124
- !this.#form.canRead(path, fieldAccessPath(schema)) ||
125
- !this.#form.canWrite(path, fieldAccessPath(schema)) ||
126
- (schema.admin.condition !== undefined &&
127
- !evaluateFieldCondition(schema.admin.condition, path, (other) => this.#form.get(other)))
128
- );
129
- })
130
- )
131
- return true;
132
- const schema = this.#schema();
133
- const accessPath = fieldAccessPath(schema);
134
- return (
135
- this.#form.editingBlocked ||
136
- schema.admin.readOnly === true ||
137
- schema.admin.hidden === true ||
138
- !this.#form.canRead(this.#path, accessPath) ||
139
- !this.#form.canWrite(this.#path, accessPath) ||
140
- (schema.admin.condition !== undefined &&
141
- !evaluateFieldCondition(schema.admin.condition, this.#path, (path) => this.#form.get(path)))
142
- );
90
+ return this.#lifetime.readOnly;
143
91
  }
144
92
  get inputProps() {
145
93
  const schema = this.schema;
@@ -167,43 +115,17 @@ export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBi
167
115
  "This field editor is stale. Use the editor mounted for the current document, locale and field occurrence."
168
116
  );
169
117
  };
170
- destroy = () => {
171
- if (this.#stale) return;
172
- this.#stale = true;
173
- this.#stopPath();
174
- this.#stopObserve();
175
- this.#stopLifetime();
176
- };
118
+ destroy = () => this.#lifetime.destroy();
177
119
  #write(value: unknown) {
178
120
  this.assertActive();
179
- if (this.readOnly) throw new Error(`Field ${this.#path} is read-only.`);
121
+ if (this.readOnly) throw new Error(`Field ${this.#lifetime.path} is read-only.`);
180
122
  assertEditorValue(this.#type, value);
181
123
  if (value === undefined) throw new Error("Use null to clear an editor value.");
182
- this.#form.set(this.#path, cloneFormValue(value));
183
- }
184
- #resolve() {
185
- if (this.#stale) return;
186
- if (
187
- this.#schema().id !== this.#schemaID ||
188
- this.#schemaVersion() !== this.#initialSchemaVersion ||
189
- !this.#form.editorScopeActive ||
190
- this.#form.editorEpoch !== this.#epoch ||
191
- this.#form.contentLocale !== this.#locale ||
192
- JSON.stringify(this.#form.resource) !== this.#resource
193
- ) {
194
- this.destroy();
195
- return;
196
- }
197
- const path = this.#occurrence.resolve();
198
- if (path === undefined) {
199
- this.destroy();
200
- return;
201
- }
202
- if (path !== this.#path) {
203
- this.#stopPath();
204
- this.#path = path;
205
- this.#stopPath = this.#form.register(path);
206
- }
124
+ const detached = cloneFormValue(value) as FieldEditorValue<Type> | null;
125
+ this.assertActive();
126
+ if (this.readOnly) throw new Error(`Field ${this.#lifetime.path} is read-only.`);
127
+ if (this.#apply !== undefined) this.#apply(detached);
128
+ else this.#form.set(this.#lifetime.path, detached);
207
129
  }
208
130
  }
209
131
 
@@ -1,7 +1,6 @@
1
1
  import { resolveBlockTypes } from "@riducms/protocol";
2
2
  import type { SchemaField } from "@riducms/protocol";
3
3
  import type { FormController } from "@admin/core/forms/form-controller.svelte";
4
- import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
5
4
  import { scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
6
5
 
7
6
  type ResolvePath = () => string | undefined;
@@ -58,20 +57,7 @@ export function captureFieldOccurrence(form: FormController, target: string): Fi
58
57
  const rowPath = () => {
59
58
  const base = path();
60
59
  if (base === undefined) return;
61
- const values = form.get(base);
62
- if (!Array.isArray(values)) return;
63
- const matches = values
64
- .map((value: unknown, index) => ({ value, index }))
65
- .filter(({ value }) => record(value) && value._key === key);
66
- const match = matches[0];
67
- if (
68
- matches.length !== 1 ||
69
- !match ||
70
- !record(match.value) ||
71
- match.value.blockType !== variant
72
- )
73
- return;
74
- return `${base}.${match.index}`;
60
+ return form.rowPath(base, key, variant);
75
61
  };
76
62
  const children =
77
63
  field.type === "array"
@@ -81,21 +67,18 @@ export function captureFieldOccurrence(form: FormController, target: string): Fi
81
67
  if (result) return result;
82
68
  }
83
69
  if (field.plugin?.embeddedTrees !== undefined) {
84
- const initial = embeddedOccurrences(field, form.get(current), current).occurrences.find(
85
- (item) => target.startsWith(`${item.path}.`)
86
- );
70
+ const initial = form
71
+ .embeddedFields({ ...field, path: current })
72
+ .occurrences.find((item) => target.startsWith(`${item.path}.`));
87
73
  if (!initial || initial.identity === undefined)
88
74
  throw new Error(`Cannot bind ${target}: embedded payloads require stable identities.`);
89
75
  const { identity, tree, block, case: branch } = initial;
90
76
  const payloadPath = () => {
91
77
  const base = path();
92
78
  if (base === undefined) return;
93
- const matches = embeddedOccurrences(field, form.get(base), base).occurrences.filter(
94
- (item) => item.tree.key === tree.key && item.identity === identity
95
- );
96
- const match = matches[0];
79
+ const match = form.embeddedOccurrence({ ...field, path: base }, tree.key, identity);
97
80
  if (
98
- matches.length !== 1 ||
81
+ match === undefined ||
99
82
  match?.block.slug !== block.slug ||
100
83
  match.case.tagValue !== branch.tagValue
101
84
  )
@@ -136,6 +136,7 @@ export class FormController {
136
136
  };
137
137
  }
138
138
  #invalidateEditors() {
139
+ this.#invalidateValueIndexes();
139
140
  this.liveValidation.reset();
140
141
  this.#listEdits.clear();
141
142
  this.editorEpoch += 1;
@@ -164,6 +165,15 @@ export class FormController {
164
165
  localizationSources = $state.raw<Readonly<Record<string, string>>>({});
165
166
  #registered = new Map<string, Set<symbol>>();
166
167
  #rowMountKeys = new WeakMap<object, symbol>();
168
+ #valueEpoch = $state(0);
169
+ #rowIndexes = new Map<string, Map<string, { index: number; variant: unknown }>>();
170
+ #embeddedIndexes = new Map<
171
+ string,
172
+ {
173
+ result: ReturnType<typeof embeddedOccurrences>;
174
+ identities: Map<string, EmbeddedOccurrence | undefined>;
175
+ }
176
+ >();
167
177
  #pathObservers = new Map<string, Set<(value: unknown) => void>>();
168
178
  #derivedTextBindings = new Map<string, DerivedTextBindingState>();
169
179
  #revision = 0;
@@ -295,6 +305,60 @@ export class FormController {
295
305
  return readPath(this.values, path);
296
306
  }
297
307
 
308
+ /** Shared identity lookups: a mounted header never scans every sibling on its own. */
309
+ rowPath(path: string, identity: string, variant: unknown) {
310
+ this.#valueEpoch;
311
+ let index = this.#rowIndexes.get(path);
312
+ if (index === undefined) {
313
+ index = new Map();
314
+ const rows = this.get(path);
315
+ if (Array.isArray(rows))
316
+ for (const [position, row] of rows.entries()) {
317
+ if (!isRecord(row) || typeof row._key !== "string") continue;
318
+ index.set(row._key, {
319
+ index: index.has(row._key) ? -1 : position,
320
+ variant: row.blockType,
321
+ });
322
+ }
323
+ this.#rowIndexes.set(path, index);
324
+ }
325
+ const match = index.get(identity);
326
+ return match !== undefined && match.index >= 0 && match.variant === variant
327
+ ? `${path}.${match.index}`
328
+ : undefined;
329
+ }
330
+
331
+ /** Cache only structural traversal, never access decisions or detached field values. */
332
+ embeddedFields(field: SchemaField) {
333
+ this.#valueEpoch;
334
+ const key = field.path;
335
+ let entry = this.#embeddedIndexes.get(key);
336
+ if (entry === undefined) {
337
+ const result = embeddedOccurrences(field, this.get(field.path), field.path);
338
+ const identities = new Map<string, EmbeddedOccurrence | undefined>();
339
+ for (const occurrence of result.occurrences) {
340
+ const identity = JSON.stringify([occurrence.tree.key, occurrence.identity]);
341
+ identities.set(identity, identities.has(identity) ? undefined : occurrence);
342
+ }
343
+ entry = { result, identities };
344
+ this.#embeddedIndexes.set(key, entry);
345
+ }
346
+ return entry.result;
347
+ }
348
+
349
+ embeddedOccurrence(field: SchemaField, treeKey: string, identity: string) {
350
+ this.embeddedFields(field);
351
+ return this.#embeddedIndexes
352
+ .get(field.path)
353
+ ?.identities.get(JSON.stringify([treeKey, identity]));
354
+ }
355
+
356
+ #invalidateValueIndexes() {
357
+ this.#rowIndexes.clear();
358
+ this.#embeddedIndexes.clear();
359
+ this.#valueEpoch++;
360
+ }
361
+
298
362
  /** DOM identity survives retained-row replacements, but not removal and key reuse. */
299
363
  rowMountKey(row: Record<string, unknown>) {
300
364
  const key = this.#rowMountKeys.get(row) ?? Symbol();
@@ -333,6 +397,7 @@ export class FormController {
333
397
  if (key) mounts.set(token, { row, key });
334
398
  }
335
399
  writePath(this.values, path, value);
400
+ this.#invalidateValueIndexes();
336
401
  if (root) {
337
402
  const retained = new Set<string>();
338
403
  for (const { token, schema, path: fieldPath, value: current } of indexFieldValues(
@@ -540,6 +605,7 @@ export class FormController {
540
605
  this.#invalidateEditors();
541
606
  this.#clearDerivedTextBindings();
542
607
  this.values = cloneFormValues(values);
608
+ this.#invalidateValueIndexes();
543
609
  this.original = cloneFormValues(values);
544
610
  this.localizationSources = {};
545
611
  this.issues = [];
@@ -558,6 +624,7 @@ export class FormController {
558
624
  this.#fields = nextFields;
559
625
  const result = reconcileFormSchema(this, previousFields, nextFields, { initializeDefaults });
560
626
  this.values = result.values;
627
+ this.#invalidateValueIndexes();
561
628
  this.original = result.original;
562
629
  this.issues = [];
563
630
  this.#submittingIssues = [];
@@ -575,6 +642,7 @@ export class FormController {
575
642
  this.#fields = nextFields;
576
643
  const result = recoverFormDraft(this, draft, previousFields, nextFields);
577
644
  this.values = result.values;
645
+ this.#invalidateValueIndexes();
578
646
  this.original = result.original;
579
647
  this.issues = [];
580
648
  this.#submittingIssues = [];
@@ -93,6 +93,7 @@ export function guardPluginAuthoring(
93
93
  },
94
94
  }),
95
95
  ...(host.schemaForm === undefined ? {} : { schemaForm: host.schemaForm }),
96
+ ...(host.schemaHeader === undefined ? {} : { schemaHeader: host.schemaHeader }),
96
97
  ...(host.schemaIssues === undefined
97
98
  ? {}
98
99
  : {