Fix `{{pick}}` macro seeding inside delayed-resolution macros like `{{if}}` (#4986) - Thread `contextOffset` through `MacroEngine.evaluate()` to preserve global document position - Update `resolve()` in execution context to pass caller's `globalOffset` as `contextOffset` - Add `offsetDelta` option to `resolve()` for additional offset customization - Add regression tests for `{{pick}}` inside `{{if}}` blocks with `delayArgResolution` - Ensure identical picks at different positions produce different results

5c2a02a128218a8460f92b67f39c2a746851bc08

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +66 -4Showing whitespace changes
public/scripts/macros/engine/MacroEngine.js+6 -2
@@ -108,9 +108,13 @@ class MacroEngine {
108108 *
109109 * @param {string} input - The input string to evaluate.
110110 * @param {MacroEnv} env - The environment to pass to the macro handler.
111+ * @param {Object} [options={}] - Optional evaluation settings.
112+ * @param {number} [options.contextOffset=0] - Base offset from the original top-level document.
113+ * Used when evaluating nested content (via resolve() in handlers) to preserve global
114+ * positioning for macros like {{pick}} that seed on position.
111115 * @returns {string} The resolved string.
112116 */
113117 evaluate(input, env, { contextOffset = 0 } = {}) {
114118 if (!input) {
115119 return '';
116120 }
@@ -138,7 +142,7 @@ class MacroEngine {
138142 try {
139143 evaluated = MacroCstWalker.evaluateDocument({
140144 text: preProcessed,
141145 contextOffset: 0,
142146 cst,
143147 env: safeEnv,
144148 resolveMacro: this.#resolveMacro.bind(this),
public/scripts/macros/engine/MacroRegistry.js+7 -2
@@ -121,7 +121,10 @@ export const MacroValueType = Object.freeze({
121121 * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results.
122122 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
123123 * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.
124124 * @property {(text: string, options?: { offsetDelta?: number }) => string} resolve - Evaluates macros in the given text using the same environment. Use when delayArgResolution is true.
125+ * Use when delayArgResolution is true. By default, preserves the caller's globalOffset so nested
126+ * macros like {{pick}} maintain deterministic position-based behavior. Pass offsetDelta to add
127+ * an additional offset for uniqueness (e.g., to differentiate between multiple resolve calls).
125128 */
126129
127130/**
@@ -369,7 +372,9 @@ class MacroRegistry {
369372 globalOffset: call.globalOffset,
370373 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
371374 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
372375 resolve: (text, { offsetDelta = 0 } = {}) => MacroEngine.evaluate(text, call.env), {
376+ contextOffset: call.globalOffset + offsetDelta,
377+ }),
373378 };
374379
375380 const result = def.handler(executionContext);
tests/frontend/MacroEngine.e2e.js+53 -0
@@ -818,6 +818,59 @@ test.describe('MacroEngine', () => {
818818 // Should be one of the valid options
819819 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
820820 });
821+
822+ test('should use different seeds for identical picks inside different if blocks (delayArgResolution)', async ({ page }) => {
823+ await registerTestablePick(page);
824+
825+ // Key regression test: picks inside {{if}} blocks use resolve() which must preserve globalOffset
826+ // This tests the fix for macros with delayArgResolution that call resolve() internally
827+ const output = await page.evaluate(async () => {
828+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
829+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
830+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
831+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
832+
833+ // Two identical pick macros inside different if blocks
834+ // Before the fix, both would get contextOffset=0 when resolve() was called
835+ // After the fix, resolve() passes the caller's globalOffset as contextOffset
836+ const input = '{{if true}}{{testablePick::A::B::C}}{{/if}}###{{if true}}{{testablePick::A::B::C}}{{/if}}';
837+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
838+ return MacroEngine.evaluate(input, env);
839+ });
840+
841+ const parts = output.split('###');
842+ expect(parts.length).toBe(2);
843+
844+ const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
845+ const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
846+
847+ expect(seed1).toBeTruthy();
848+ expect(seed2).toBeTruthy();
849+ // Seeds must be different because the {{if}} blocks are at different positions
850+ expect(seed1).not.toBe(seed2);
851+ });
852+
853+ test('should maintain stability for picks inside if blocks across evaluations', async ({ page }) => {
854+ // Picks inside if blocks should still be deterministic
855+ const outputs = await page.evaluate(async () => {
856+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
857+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
858+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
859+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
860+
861+ const input = '{{if true}}{{pick::X::Y::Z}}{{/if}}';
862+ const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
863+ const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
864+ const result1 = MacroEngine.evaluate(input, env1);
865+ const result2 = MacroEngine.evaluate(input, env2);
866+ return [result1, result2];
867+ });
868+
869+ // Same input should produce same output (deterministic)
870+ expect(outputs[0]).toBe(outputs[1]);
871+ // Should be one of the valid options
872+ expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
873+ });
821874 });
822875
823876 test.describe('Dynamic macros', () => {