assign-gingerly 0.0.59 → 0.0.60

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.
@@ -18,7 +18,7 @@
18
18
  * },
19
19
  * fromEachItem: {
20
20
  * assignToFragment: { '?.querySelector?.tr?.ish': '?.' },
21
- * withOptions: { withMethods: ['querySelector'], inferredAssignments: true },
21
+ * withOptions: { withMethods: ['querySelector'], infer: true },
22
22
  * resolve: { key: '?.rank' }
23
23
  * }
24
24
  * }
@@ -75,6 +75,7 @@ export class ManageTemplateListHandler implements AssignFromHandler {
75
75
  }
76
76
 
77
77
  const fromEachItem = this.config.fromEachItem;
78
+ const configs = fromEachItem?.configs; // Array form for multi-element templates
78
79
  const assignToFragment = fromEachItem?.assignToFragment ?? {};
79
80
  const withOptions = fromEachItem?.withOptions ?? {};
80
81
  const perItemResolve = fromEachItem?.resolve ?? {};
@@ -85,10 +86,10 @@ export class ManageTemplateListHandler implements AssignFromHandler {
85
86
  const sourceAssignToFragment = fromSource?.assignToFragment;
86
87
  const sourceWithOptions = fromSource?.withOptions ?? {};
87
88
 
88
- // Detect fast path: no assignToFragment patterns, just inferredAssignments
89
- const hasAssignPatterns = Object.keys(assignToFragment).length > 0;
90
- const inferredConfig = withOptions.inferredAssignments;
91
- const useFastPath = !hasAssignPatterns && inferredConfig && !sourceAssignToFragment;
89
+ // Detect fast path: no assignToFragment patterns, just infer (only for non-configs mode)
90
+ const hasAssignPatterns = !configs && Object.keys(assignToFragment).length > 0;
91
+ const inferredConfig = !configs && withOptions.infer;
92
+ const useFastPath = !configs && !hasAssignPatterns && inferredConfig && !sourceAssignToFragment;
92
93
 
93
94
  const name = markerName ?? getMarkerName(instantiate) ?? 'templateList';
94
95
 
@@ -149,17 +150,28 @@ export class ManageTemplateListHandler implements AssignFromHandler {
149
150
  if (state.keyToNodes.has(key) && oldKeys.has(key)) {
150
151
  // Existing item — update in place
151
152
  const existingNodes = state.keyToNodes.get(key)!;
152
- const rootEl = existingNodes.find(n => n instanceof Element) as Element | undefined;
153
- if (rootEl) {
154
- const shouldYield = yieldEvery && i > 0 && i % yieldEvery === 0;
155
- if (shouldYield) await new Promise(r => setTimeout(r, 0));
156
- if (processInferred) {
157
- processInferred(rootEl, item, inferredConfig === true ? { byItemprop: true } : inferredConfig);
158
- } else {
159
- assignFrom(rootEl, assignToFragment, { from: item, ...withOptions });
153
+ const shouldYield = yieldEvery && i > 0 && i % yieldEvery === 0;
154
+ if (shouldYield) await new Promise(r => setTimeout(r, 0));
155
+
156
+ if (configs) {
157
+ // Multi-element: zip configs with element nodes
158
+ const elements = existingNodes.filter(n => n instanceof Element) as Element[];
159
+ const len = Math.min(elements.length, configs.length);
160
+ for (let j = 0; j < len; j++) {
161
+ const cfg = configs[j];
162
+ assignFrom(elements[j], cfg.assignToFragment ?? {}, { from: item, ...cfg.withOptions });
160
163
  }
161
- if (sourceAssignToFragment && options?.from) {
162
- assignFrom(rootEl, sourceAssignToFragment, { from: options.from, ...sourceWithOptions });
164
+ } else {
165
+ const rootEl = existingNodes.find(n => n instanceof Element) as Element | undefined;
166
+ if (rootEl) {
167
+ if (processInferred) {
168
+ processInferred(rootEl, item, inferredConfig === true ? { byItemprop: true } : inferredConfig);
169
+ } else {
170
+ assignFrom(rootEl, assignToFragment, { from: item, ...withOptions });
171
+ }
172
+ if (sourceAssignToFragment && options?.from) {
173
+ assignFrom(rootEl, sourceAssignToFragment, { from: options.from, ...sourceWithOptions });
174
+ }
163
175
  }
164
176
  }
165
177
  newKeyToNodes.set(key, existingNodes);
@@ -180,21 +192,32 @@ export class ManageTemplateListHandler implements AssignFromHandler {
180
192
  const clonedNodes = Array.from(content.childNodes);
181
193
 
182
194
  // Apply per-item assignments to the cloned fragment
183
- const rootEl = clonedNodes.find(n => n instanceof Element) as Element | undefined;
184
- if (rootEl) {
185
- const tempContainer = document.createDocumentFragment();
186
- tempContainer.appendChild(content);
187
-
188
- const shouldYield = yieldEvery && i > 0 && i % yieldEvery === 0;
189
- if (shouldYield) await new Promise(r => setTimeout(r, 0));
190
- if (processInferred) {
191
- processInferred(rootEl, item, inferredConfig === true ? { byItemprop: true } : inferredConfig);
192
- } else {
193
- assignFrom(rootEl, assignToFragment, { from: item, ...withOptions });
195
+ const shouldYield = yieldEvery && i > 0 && i % yieldEvery === 0;
196
+ if (shouldYield) await new Promise(r => setTimeout(r, 0));
197
+
198
+ if (configs) {
199
+ // Multi-element: zip configs with element nodes
200
+ const elements = clonedNodes.filter(n => n instanceof Element) as Element[];
201
+ const len = Math.min(elements.length, configs.length);
202
+ for (let j = 0; j < len; j++) {
203
+ const cfg = configs[j];
204
+ assignFrom(elements[j], cfg.assignToFragment ?? {}, { from: item, ...cfg.withOptions });
194
205
  }
206
+ } else {
207
+ const rootEl = clonedNodes.find(n => n instanceof Element) as Element | undefined;
208
+ if (rootEl) {
209
+ const tempContainer = document.createDocumentFragment();
210
+ tempContainer.appendChild(content);
195
211
 
196
- if (sourceAssignToFragment && options?.from) {
197
- assignFrom(rootEl, sourceAssignToFragment, { from: options.from, ...sourceWithOptions });
212
+ if (processInferred) {
213
+ processInferred(rootEl, item, inferredConfig === true ? { byItemprop: true } : inferredConfig);
214
+ } else {
215
+ assignFrom(rootEl, assignToFragment, { from: item, ...withOptions });
216
+ }
217
+
218
+ if (sourceAssignToFragment && options?.from) {
219
+ assignFrom(rootEl, sourceAssignToFragment, { from: options.from, ...sourceWithOptions });
220
+ }
198
221
  }
199
222
  }
200
223
 
@@ -81,38 +81,26 @@ export class Infer {
81
81
  return inferValueProperty(this.enhancedElement);
82
82
  }
83
83
  ['|'](itempropAttr, scopeBoundary = '[itemscope]') {
84
- const candidates = this.enhancedElement.querySelectorAll(`[itemprop="${itempropAttr}"]`);
85
- return Array.from(candidates)
86
- .filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
87
- .map(x => new Infer(x, itempropAttr));
84
+ return this.#queryScoped(`[itemprop="${itempropAttr}"]`, itempropAttr, scopeBoundary);
88
85
  }
89
86
  ['@'](nameAttr, scopeBoundary) {
90
- const candidates = this.enhancedElement.querySelectorAll(`[name="${nameAttr}"]`);
91
- const filtered = scopeBoundary
92
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
93
- : Array.from(candidates);
94
- return filtered.map(x => new Infer(x, nameAttr));
87
+ return this.#queryScoped(`[name="${nameAttr}"]`, nameAttr, scopeBoundary);
95
88
  }
96
89
  ['%'](partAttr, scopeBoundary) {
97
- const candidates = this.enhancedElement.querySelectorAll(`[part~="${partAttr}"]`);
98
- const filtered = scopeBoundary
99
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
100
- : Array.from(candidates);
101
- return filtered.map(x => new Infer(x, partAttr));
90
+ return this.#queryScoped(`[part~="${partAttr}"]`, partAttr, scopeBoundary);
102
91
  }
103
92
  ['#'](id, scopeBoundary) {
104
- const candidates = this.enhancedElement.querySelectorAll(`#${id}`);
105
- const filtered = scopeBoundary
106
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
107
- : Array.from(candidates);
108
- return filtered.map(x => new Infer(x, id));
93
+ return this.#queryScoped(`#${id}`, id, scopeBoundary);
109
94
  }
110
95
  ['.'](className, scopeBoundary) {
111
- const candidates = this.enhancedElement.querySelectorAll(`.${className}`);
96
+ return this.#queryScoped(`.${className}`, className, scopeBoundary);
97
+ }
98
+ #queryScoped(selector, propName, scopeBoundary) {
99
+ const candidates = this.enhancedElement.querySelectorAll(selector);
112
100
  const filtered = scopeBoundary
113
101
  ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
114
102
  : Array.from(candidates);
115
- return filtered.map(x => new Infer(x, className));
103
+ return filtered.map(x => new Infer(x, propName));
116
104
  }
117
105
  setDisplay(vm) {
118
106
  const val = this.#propName ? vm[this.#propName] : inferBindingProperty(this.enhancedElement);
@@ -103,42 +103,31 @@ export class Infer<TValue = any, TDisplay = any> {
103
103
  }
104
104
 
105
105
  ['|'](itempropAttr: string, scopeBoundary: string = '[itemscope]'){
106
- const candidates = this.enhancedElement.querySelectorAll(`[itemprop="${itempropAttr}"]`);
107
- return Array.from(candidates)
108
- .filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
109
- .map(x => new Infer(x, itempropAttr));
106
+ return this.#queryScoped(`[itemprop="${itempropAttr}"]`, itempropAttr, scopeBoundary);
110
107
  }
111
108
 
112
109
  ['@'](nameAttr: string, scopeBoundary?: string){
113
- const candidates = this.enhancedElement.querySelectorAll(`[name="${nameAttr}"]`);
114
- const filtered = scopeBoundary
115
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
116
- : Array.from(candidates);
117
- return filtered.map(x => new Infer(x, nameAttr));
110
+ return this.#queryScoped(`[name="${nameAttr}"]`, nameAttr, scopeBoundary);
118
111
  }
119
112
 
120
113
  ['%'](partAttr: string, scopeBoundary?: string){
121
- const candidates = this.enhancedElement.querySelectorAll(`[part~="${partAttr}"]`);
122
- const filtered = scopeBoundary
123
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
124
- : Array.from(candidates);
125
- return filtered.map(x => new Infer(x, partAttr));
114
+ return this.#queryScoped(`[part~="${partAttr}"]`, partAttr, scopeBoundary);
126
115
  }
127
116
 
128
117
  ['#'](id: string, scopeBoundary?: string){
129
- const candidates = this.enhancedElement.querySelectorAll(`#${id}`);
130
- const filtered = scopeBoundary
131
- ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
132
- : Array.from(candidates);
133
- return filtered.map(x => new Infer(x, id));
118
+ return this.#queryScoped(`#${id}`, id, scopeBoundary);
134
119
  }
135
120
 
136
121
  ['.'](className: string, scopeBoundary?: string){
137
- const candidates = this.enhancedElement.querySelectorAll(`.${className}`);
122
+ return this.#queryScoped(`.${className}`, className, scopeBoundary);
123
+ }
124
+
125
+ #queryScoped(selector: string, propName: string, scopeBoundary?: string): Infer[] {
126
+ const candidates = this.enhancedElement.querySelectorAll(selector);
138
127
  const filtered = scopeBoundary
139
128
  ? Array.from(candidates).filter(el => withScopePerimeter(this.enhancedElement, el, scopeBoundary))
140
129
  : Array.from(candidates);
141
- return filtered.map(x => new Infer(x, className));
130
+ return filtered.map(x => new Infer(x, propName));
142
131
  }
143
132
 
144
133
  setDisplay(vm: any){
@@ -17,11 +17,14 @@ export function processInferredAssignments(target, from, config) {
17
17
  return;
18
18
  if (!from || typeof from !== 'object')
19
19
  return;
20
- const { byItemprop } = config;
21
- if (byItemprop) {
22
- const keys = byItemprop === true
20
+ const { byItemprop, byName } = config;
21
+ // Resolve aliases: '|' → byItemprop, '@' → byName
22
+ const effectiveByItemprop = byItemprop ?? config['|'];
23
+ const effectiveByName = byName ?? config['@'];
24
+ if (effectiveByItemprop) {
25
+ const keys = effectiveByItemprop === true
23
26
  ? Object.keys(from)
24
- : byItemprop;
27
+ : effectiveByItemprop;
25
28
  const infer = new Infer(target);
26
29
  for (const key of keys) {
27
30
  if (!(key in from))
@@ -35,4 +38,31 @@ export function processInferredAssignments(target, from, config) {
35
38
  }
36
39
  }
37
40
  }
41
+ if (effectiveByName) {
42
+ // Normalize config
43
+ let keys;
44
+ let scopeBoundary;
45
+ if (effectiveByName === true) {
46
+ keys = Object.keys(from);
47
+ }
48
+ else if (Array.isArray(effectiveByName)) {
49
+ keys = effectiveByName;
50
+ }
51
+ else {
52
+ // Object form: { props, outside }
53
+ keys = effectiveByName.props === true ? Object.keys(from) : effectiveByName.props;
54
+ scopeBoundary = effectiveByName.outside;
55
+ }
56
+ const infer = new Infer(target);
57
+ for (const key of keys) {
58
+ if (!(key in from))
59
+ continue;
60
+ const value = from[key];
61
+ // Use inferencer's ['@'] method — by name attribute, optional scope boundary
62
+ const matches = infer['@'](key, scopeBoundary);
63
+ for (const match of matches) {
64
+ match.value = value;
65
+ }
66
+ }
67
+ }
38
68
  }
@@ -20,8 +20,23 @@ export interface InferredAssignmentsConfig {
20
20
  */
21
21
  byItemprop?: string[] | true;
22
22
 
23
- // Phase II:
24
- // byName?: string[] | true;
23
+ /** Concise alias for byItemprop */
24
+ '|'?: string[] | true;
25
+
26
+ /**
27
+ * Array of property keys to distribute by name attribute.
28
+ * For each key, finds [name="${key}"] elements and sets the value using
29
+ * the inferred property (value, checked, etc.).
30
+ *
31
+ * Pass `true` to infer all keys from the `from` source object.
32
+ *
33
+ * Object form enables donut-hole scoping:
34
+ * { props: ['firstName', 'lastName'], outside: 'fieldset' }
35
+ */
36
+ byName?: string[] | true | { props: string[] | true; outside: string };
37
+
38
+ /** Concise alias for byName */
39
+ '@'?: string[] | true | { props: string[] | true; outside: string };
25
40
  }
26
41
 
27
42
  /**
@@ -39,12 +54,16 @@ export function processInferredAssignments(
39
54
  if (!(target instanceof Element)) return;
40
55
  if (!from || typeof from !== 'object') return;
41
56
 
42
- const { byItemprop } = config;
57
+ const { byItemprop, byName } = config;
43
58
 
44
- if (byItemprop) {
45
- const keys = byItemprop === true
59
+ // Resolve aliases: '|' → byItemprop, '@' → byName
60
+ const effectiveByItemprop = byItemprop ?? config['|'];
61
+ const effectiveByName = byName ?? config['@'];
62
+
63
+ if (effectiveByItemprop) {
64
+ const keys = effectiveByItemprop === true
46
65
  ? Object.keys(from)
47
- : byItemprop;
66
+ : effectiveByItemprop;
48
67
 
49
68
  const infer = new Infer(target);
50
69
 
@@ -62,4 +81,35 @@ export function processInferredAssignments(
62
81
  }
63
82
  }
64
83
  }
84
+
85
+ if (effectiveByName) {
86
+ // Normalize config
87
+ let keys: string[];
88
+ let scopeBoundary: string | undefined;
89
+
90
+ if (effectiveByName === true) {
91
+ keys = Object.keys(from);
92
+ } else if (Array.isArray(effectiveByName)) {
93
+ keys = effectiveByName;
94
+ } else {
95
+ // Object form: { props, outside }
96
+ keys = effectiveByName.props === true ? Object.keys(from) : effectiveByName.props;
97
+ scopeBoundary = effectiveByName.outside;
98
+ }
99
+
100
+ const infer = new Infer(target);
101
+
102
+ for (const key of keys) {
103
+ if (!(key in from)) continue;
104
+
105
+ const value = from[key];
106
+
107
+ // Use inferencer's ['@'] method — by name attribute, optional scope boundary
108
+ const matches: Infer[] = infer['@'](key, scopeBoundary);
109
+
110
+ for (const match of matches) {
111
+ match.value = value;
112
+ }
113
+ }
114
+ }
65
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
@@ -150,6 +150,18 @@
150
150
  "default": "./assignFrom.js",
151
151
  "types": "./assignFrom.ts"
152
152
  },
153
+ "./assignFrom-extension.js": {
154
+ "default": "./assignFrom-extension.js",
155
+ "types": "./assignFrom-extension.ts"
156
+ },
157
+ "./assignFromAsync-extension.js": {
158
+ "default": "./assignFromAsync-extension.js",
159
+ "types": "./assignFromAsync-extension.ts"
160
+ },
161
+ "./builtInEmoji.js": {
162
+ "default": "./builtInEmoji.js",
163
+ "types": "./builtInEmoji.ts"
164
+ },
153
165
  "./assignFeatures.js": {
154
166
  "default": "./assignFeatures.js",
155
167
  "types": "./assignFeatures.ts"
@@ -16,9 +16,9 @@ export default defineConfig({
16
16
  /* Fail the build on CI if you accidentally left test.only in the source code. */
17
17
  forbidOnly: !!process.env.CI,
18
18
  /* Retry on CI only */
19
- retries: process.env.CI ? 2 : 0,
19
+ retries: process.env.CI ? 1 : 0,
20
20
  /* Opt out of parallel tests on CI. */
21
- workers: process.env.CI ? 1 : undefined,
21
+ workers: process.env.CI ? 2 : undefined,
22
22
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
23
23
  reporter: [ ['html', { open: 'never' }] ],
24
24
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@@ -50,5 +50,6 @@ export default defineConfig({
50
50
  command: 'npm run serve',
51
51
  port: 8000,
52
52
  reuseExistingServer: !process.env.CI,
53
+ timeout: 30000,
53
54
  },
54
55
  });
@@ -70,8 +70,13 @@ async function resolveFromHandlers(name, handlers, permissions) {
70
70
  if (typeof entry === 'function') {
71
71
  return entry;
72
72
  }
73
- // Import path string validate and dynamically import
73
+ // String valuecould be a built-in alias or an import path
74
74
  if (typeof entry === 'string') {
75
+ // Built-in alias: redirect to built-in loader
76
+ if (entry.startsWith('builtIns.')) {
77
+ return loadBuiltIn(entry);
78
+ }
79
+ // Import path string — validate and dynamically import
75
80
  if (!permissions?.crossDomainImports && !isAllowedImportPath(entry)) {
76
81
  throw new Error(`assignFrom: handler "${name}" has an invalid import path "${entry}". ` +
77
82
  `Only relative, absolute, or bare specifier paths are allowed (no cross-domain URLs). ` +
@@ -81,8 +81,14 @@ async function resolveFromHandlers(
81
81
  return entry as AssignFromHandlerConstructor;
82
82
  }
83
83
 
84
- // Import path string validate and dynamically import
84
+ // String valuecould be a built-in alias or an import path
85
85
  if (typeof entry === 'string') {
86
+ // Built-in alias: redirect to built-in loader
87
+ if (entry.startsWith('builtIns.')) {
88
+ return loadBuiltIn(entry);
89
+ }
90
+
91
+ // Import path string — validate and dynamically import
86
92
  if (!permissions?.crossDomainImports && !isAllowedImportPath(entry)) {
87
93
  throw new Error(
88
94
  `assignFrom: handler "${name}" has an invalid import path "${entry}". ` +
package/resolveIdRef.js CHANGED
@@ -18,14 +18,15 @@ const idCacheMap = new WeakMap();
18
18
  const idCounterMap = new WeakMap();
19
19
  /**
20
20
  * Generate a unique ID within a rootNode.
21
- * Format: _ag0, _ag1, _ag2, ...
21
+ * Format: -ag:0, -ag:1, -ag:2, ...
22
+ * Starts with '-' and contains ':' to avoid collision with JS identifiers/globalThis properties.
22
23
  */
23
24
  function generateUniqueId(rootNode) {
24
25
  let counter = idCounterMap.get(rootNode) ?? 0;
25
26
  let id;
26
27
  // Ensure uniqueness (skip if ID already exists in the document)
27
28
  do {
28
- id = `_ag${counter}`;
29
+ id = `-ag:${counter}`;
29
30
  counter++;
30
31
  } while (rootNode.getElementById?.(id));
31
32
  idCounterMap.set(rootNode, counter);
@@ -43,6 +44,55 @@ export function resolveIdVariable(varName, target, withIds) {
43
44
  const config = withIds[varName];
44
45
  if (config === undefined)
45
46
  return undefined;
47
+ // For 'at' option path-based configs (array or { path }), resolve directly from target — no ID, no caching
48
+ // These are target-relative and fast (~2-4ns), for when structure is guaranteed stable
49
+ if (Array.isArray(config)) {
50
+ let current = target;
51
+ for (const idx of config) {
52
+ if (!current || !current.children)
53
+ break;
54
+ current = current.children[idx];
55
+ }
56
+ return current instanceof Element ? current : undefined;
57
+ }
58
+ if (typeof config === 'object' && 'path' in config && !('qry' in config)) {
59
+ // Determine if this is from 'at' (no ID assignment) or 'withIds' with path (assigns ID + caches)
60
+ // When called from 'at', we skip ID/caching. When from 'withIds', we assign ID and cache.
61
+ // Distinguish by presence in the options — caller passes the merged map.
62
+ // For now: { path } without 'noId' → assign ID + cache (withIds behavior)
63
+ const rootNode = target.getRootNode?.() ?? target;
64
+ let current = target;
65
+ for (const idx of config.path) {
66
+ if (!current || !current.children)
67
+ break;
68
+ current = current.children[idx];
69
+ }
70
+ let el = current instanceof Element ? current : null;
71
+ // Validation: check if resolved element matches expected selector
72
+ if (config.expect) {
73
+ const didNotMatch = !el || !el.matches(config.expect);
74
+ if (didNotMatch) {
75
+ if (config.fallback) {
76
+ el = target.querySelector?.(config.expect) ?? el;
77
+ }
78
+ // Fire-and-forget: log correction suggestion
79
+ const capturedConfig = config;
80
+ const capturedVarName = varName;
81
+ import('./withIdsCorrector.js').then(module => {
82
+ module.logConfigCorrection(target, capturedVarName, capturedConfig);
83
+ }).catch(() => { });
84
+ }
85
+ }
86
+ if (!el)
87
+ return undefined;
88
+ // Assign ID for stability against future DOM mutations
89
+ let id = el.id;
90
+ if (!id) {
91
+ id = generateUniqueId(rootNode);
92
+ el.id = id;
93
+ }
94
+ return el;
95
+ }
46
96
  const rootNode = target.getRootNode?.() ?? target;
47
97
  // Get or create cache for this rootNode
48
98
  let cache = idCacheMap.get(rootNode);
@@ -50,28 +100,27 @@ export function resolveIdVariable(varName, target, withIds) {
50
100
  cache = new Map();
51
101
  idCacheMap.set(rootNode, cache);
52
102
  }
53
- // Check cache first
54
- const cached = cache.get(varName);
55
- if (cached) {
56
- const el = cached.ref.deref();
57
- if (el)
58
- return el;
59
- // WeakRef was collected — try getElementById fallback
60
- const el2 = rootNode.getElementById?.(cached.id);
61
- if (el2) {
62
- cache.set(varName, { id: cached.id, ref: new WeakRef(el2) });
63
- return el2;
64
- }
65
- // Element no longer exists — fall through to re-query
66
- }
67
103
  // First time or cache miss — resolve the element
68
104
  let el = null;
69
105
  if (typeof config === 'string') {
70
106
  // String form: existing ID — use getElementById directly
107
+ // Check cache first (getElementById lookups are cacheable — global to rootNode)
108
+ const cached = cache.get(varName);
109
+ if (cached) {
110
+ const cachedEl = cached.ref.deref();
111
+ if (cachedEl)
112
+ return cachedEl;
113
+ // WeakRef was collected — try getElementById fallback
114
+ const el2 = rootNode.getElementById?.(cached.id);
115
+ if (el2) {
116
+ cache.set(varName, { id: cached.id, ref: new WeakRef(el2) });
117
+ return el2;
118
+ }
119
+ }
71
120
  el = rootNode.getElementById?.(config) ?? null;
72
121
  }
73
122
  else {
74
- // Object form: { qry } — run querySelector against target
123
+ // Object form: { qry } — run querySelector against target (target-relative, no cache)
75
124
  el = target.querySelector?.(config.qry) ?? null;
76
125
  }
77
126
  if (!el)
@@ -82,8 +131,10 @@ export function resolveIdVariable(varName, target, withIds) {
82
131
  id = generateUniqueId(rootNode);
83
132
  el.id = id;
84
133
  }
85
- // Cache it
86
- cache.set(varName, { id, ref: new WeakRef(el) });
134
+ // Cache only for string-form (getElementById) — not for qry form (target-relative)
135
+ if (typeof config === 'string') {
136
+ cache.set(varName, { id, ref: new WeakRef(el) });
137
+ }
87
138
  return el;
88
139
  }
89
140
  /**