From 07d3d6054304d12377c0122f80b4582ee4ea53aa Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 19 Aug 2026 18:06:29 -0300 Subject: [PATCH] fix(matchers): resolve four silent matcher-resolution defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four made a content variant lose silently — no error, no log, HTTP 200, just the fallback block rendering. Diagnosed in production on deco-sites/farmrio-storefront, where an ETC-segment topbar lost to FARM's on every sub-brand PDP. 1. pathname `type: "Template"` was unimplemented. The CMS emits route syntax (`/:slug/p`); it fell into the `Includes` default and tested whether the pathname contained the literal string — never true. Now compiled via URLPattern (workerd, Node >= 23) with a `:param`/`*` regex fallback for the Node/RSC target, memoized in a 100-entry LRU. 2. queryString read only the flat `{ key, value }` shape. The CMS emits `conditions: [{ param, case: { type, value } }]`, so `key` was always undefined and the matcher was unconditionally false. Both shapes are now accepted; conditions AND together; Equals / Not Equals / Includes / Not Includes are handled, with Equals still the default so unknown types behave exactly as before. 3. resolveSiteGlobals() resolved with no matcher context, so no `site.global` section could carry a URL-, date- or cookie-dependent variant — which is where the multivariate block lives. The context now reaches resolvePageSections, and the single cache became a keyed LRU. The key includes the sorted, tracking-param-free query string: keyed on path alone, /x/p?brand=farm and /x/p?brand=farmetc collide and the first request on a cold path picks the variant for both. Device and the deco_segment cookie are in the key too, since device and A/B matchers are as common in site.global as URL ones and would otherwise fail the same way. 4. The matcher override contract was order-dependent: registerBuiltinMatchers() overwrote unconditionally, so a site override registered after createSiteSetup() could win in vite dev and be silently lost in the same site's production build. Contract is now explicit — registerMatcher(key, fn) is the site extension point and always wins. Builtin paths pass `{ builtin: true }`, which yields to a site-owned key and overwrites only another builtin, so registerBuiltinMatchers() can still replace resolve.ts's inline date matcher. Adds unregisterMatcher(key). Every fix is additive: no decofile that resolves today changes result. The matcher tests drive the raw CMS shapes (case / conditions), not a normalized form — the divergence between the two was the bug. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + packages/blocks/src/cms/index.ts | 1 + packages/blocks/src/cms/resolve.ts | 56 +++- packages/blocks/src/matchers/builtins.test.ts | 303 +++++++++++++++++- packages/blocks/src/matchers/builtins.ts | 213 ++++++++++-- packages/blocks/src/sdk/index.ts | 1 + packages/blocks/src/sdk/urlUtils.ts | 10 + packages/tanstack/src/routes/cmsRoute.ts | 4 +- .../src/routes/withSiteGlobals.test.ts | 163 ++++++++++ .../tanstack/src/routes/withSiteGlobals.ts | 151 +++++++-- 10 files changed, 842 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5388a446..158b2d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,3 +152,5 @@ A guardrail test (`packages/apps-commerce/src/instrumentation-guardrail.test.ts` 6. **ETag** — content-based DJB2 hash, not string length. 7. **Dependency graph direction** — see "Key Boundaries" above; this is enforced by convention, not tooling, so review new imports across package boundaries carefully. 8. **Cache/upstream observability** — a new commerce app MUST route egress through `createInstrumentedFetch` and cache upstream GETs via `createFetchCache` (`@decocms/blocks/sdk/fetchCache`). See "Cache & upstream observability" above; the guardrail test enforces the first half. +9. **Matcher override contract** — `registerMatcher(key, fn)` is the extension point for site matchers, and a site registration always beats a built-in for that key, whether it runs before or after `createSiteSetup()`. Built-in registration paths (`registerBuiltinMatchers()` in `matchers/builtins.ts`, plus the module-load block in `cms/resolve.ts`) pass `{ builtin: true }`, which yields to a site-owned key and only overwrites another built-in. Never register a built-in without that flag — doing so restores the old order-dependent behavior, where an override could work in `vite dev` and be silently lost in the same site's production build. +10. **Site globals are matcher-context-dependent** — `resolveSiteGlobals(matcherCtx)` (`@decocms/tanstack`) resolves `site.global` per request and caches by `siteGlobalsCacheKey`: path + sorted non-tracking query + device + segment cookie. Any new cache key MUST keep the query string; a path-only key makes `/x/p?brand=a` and `/x/p?brand=b` collide, and the first request on a cold path silently picks the variant for both. diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 0960fe53..8c669a3e 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -120,6 +120,7 @@ export { setDanglingReferenceHandler, setResolveErrorHandler, unregisterCommerceLoader, + unregisterMatcher, WELL_KNOWN_TYPES, } from "./resolve"; export type { diff --git a/packages/blocks/src/cms/resolve.ts b/packages/blocks/src/cms/resolve.ts index 9fa38da0..069d3c7f 100644 --- a/packages/blocks/src/cms/resolve.ts +++ b/packages/blocks/src/cms/resolve.ts @@ -545,13 +545,63 @@ const customMatchers: Record< (rule: Record, ctx: MatcherContext) => boolean > = G.__deco.customMatchers; +/** + * Keys whose current implementation is framework-owned (registered by the + * built-in registration paths). A key present in {@link customMatchers} but + * absent here was registered by a *site* and must never be clobbered by a + * later builtin registration. + * + * This is what makes the override contract order-independent, and therefore + * identical in `vite dev` and in a production build — see + * {@link registerMatcher}. + */ +const builtinMatcherKeys: Set = (G.__deco.builtinMatcherKeys ??= new Set()); + +export interface RegisterMatcherOptions { + /** + * @internal Framework-owned registration. Yields to a site override that is + * already registered for this key; overwrites another builtin. Sites must + * never pass this. + */ + builtin?: boolean; +} + +/** + * Register a matcher implementation for a `__resolveType`. + * + * **This is the extension point for site-defined matchers, and site + * registrations always win.** A plain `registerMatcher(key, fn)` call takes + * precedence over every built-in for that key, whether it runs *before* or + * *after* `createSiteSetup()` / `registerBuiltinMatchers()` — module + * evaluation order in the bundle does not change the outcome. The last site + * registration for a key wins over earlier site registrations. + */ export function registerMatcher( key: string, fn: (rule: Record, ctx: MatcherContext) => boolean, + options?: RegisterMatcherOptions, ) { + if (options?.builtin) { + // A site already claimed this key — keep the site's implementation. + if (customMatchers[key] && !builtinMatcherKeys.has(key)) return; + builtinMatcherKeys.add(key); + } else { + builtinMatcherKeys.delete(key); + } customMatchers[key] = fn; } +/** + * Remove the matcher registered for a key. No-op if absent. + * + * After this, the next builtin registration for the key is free to claim it + * again — use it to drop a site override without restarting the process. + */ +export function unregisterMatcher(key: string): void { + delete customMatchers[key]; + builtinMatcherKeys.delete(key); +} + // --------------------------------------------------------------------------- // Built-in matchers — registered through the same API as custom matchers // --------------------------------------------------------------------------- @@ -591,10 +641,8 @@ if (!G.__deco._builtinMatchersRegistered) { }; for (const [key, fn] of Object.entries(builtinMatchers)) { - // Only register if not already overridden by consumer - if (!customMatchers[key]) { - customMatchers[key] = fn; - } + // `builtin: true` — never clobbers a site override, whichever ran first. + registerMatcher(key, fn, { builtin: true }); } } diff --git a/packages/blocks/src/matchers/builtins.test.ts b/packages/blocks/src/matchers/builtins.test.ts index 61d9964c..5d082462 100644 --- a/packages/blocks/src/matchers/builtins.test.ts +++ b/packages/blocks/src/matchers/builtins.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MatcherContext } from "../cms/resolve"; -import { evaluateMatcher } from "../cms/resolve"; -import { registerBuiltinMatchers } from "./builtins"; +import { evaluateMatcher, registerMatcher, unregisterMatcher } from "../cms/resolve"; +import { createSiteSetup } from "../setup"; +import { __resetPathnamePatternCache, registerBuiltinMatchers } from "./builtins"; const LOCATION_KEY = "website/matchers/location.ts"; const DATE_KEY = "website/matchers/date.ts"; @@ -316,3 +317,301 @@ describe("dateMatcher — parity with deco-cx/apps website/matchers/date.ts", () expect(matchDate({ end: "not-a-date" })).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// pathnameMatcher — CMS `case` shape +// +// These use the exact shape the CMS emits (`{ case: { type, pathname } }`), +// not a normalized one: the production bug was precisely the divergence +// between what the CMS writes and what the matcher read. +// --------------------------------------------------------------------------- + +const PATHNAME_KEY = "website/matchers/pathname.ts"; +const QUERYSTRING_KEY = "website/matchers/queryString.ts"; + +function matchPathname(rule: Record, path: string): boolean { + return evaluateMatcher({ ...rule, __resolveType: PATHNAME_KEY }, { path }); +} + +describe("pathnameMatcher — CMS case shape", () => { + beforeEach(() => { + __resetPathnamePatternCache(); + }); + + it("matches a route template against a PDP path", () => { + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/vestido-x/p")).toBe( + true, + ); + }); + + it("does not match a template when the segment count differs", () => { + const rule = { case: { type: "Template", pathname: "/:slug/p" } }; + expect(matchPathname(rule, "/farm-etc")).toBe(false); + expect(matchPathname(rule, "/a/b/p")).toBe(false); + }); + + it("does not treat the template as a literal substring (the shipped bug)", () => { + // Pre-fix, `Template` fell into the `Includes` default and tested + // path.includes("/:slug/p") — false for every real URL. + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/:slug/p")).toBe( + true, + ); + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/x/p")).toBe(true); + }); + + it("supports a wildcard template", () => { + const rule = { case: { type: "Template", pathname: "/*/p" } }; + expect(matchPathname(rule, "/vestido/p")).toBe(true); + expect(matchPathname(rule, "/vestido")).toBe(false); + }); + + it("matches templates identically with no global URLPattern (older Node / the nextjs target)", () => { + const original = (globalThis as Record).URLPattern; + // biome-ignore lint/performance/noDelete: restoring the global needs a true absence + delete (globalThis as Record).URLPattern; + try { + __resetPathnamePatternCache(); + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/x/p")).toBe(true); + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/x")).toBe(false); + expect(matchPathname({ case: { type: "Template", pathname: "/:slug/p" } }, "/a/b/p")).toBe( + false, + ); + expect(matchPathname({ case: { type: "Template", pathname: "/*/p" } }, "/x/p")).toBe(true); + // Literal dots stay literal, not regex wildcards. + expect(matchPathname({ case: { type: "Template", pathname: "/a.b" } }, "/axb")).toBe(false); + } finally { + (globalThis as Record).URLPattern = original; + __resetPathnamePatternCache(); + } + }); + + it("returns false for an unsafe/uncompilable template instead of throwing", () => { + expect(matchPathname({ case: { type: "Template", pathname: "/(((" } }, "/x/p")).toBe(false); + }); + + it("reuses the compiled pattern across calls (cache is transparent)", () => { + const rule = { case: { type: "Template", pathname: "/:slug/p" } }; + expect(matchPathname(rule, "/a/p")).toBe(true); + expect(matchPathname(rule, "/b/p")).toBe(true); + expect(matchPathname(rule, "/b")).toBe(false); + }); + + // --- regressions: the pre-existing case types must be untouched --- + + it("still handles Includes", () => { + expect(matchPathname({ case: { type: "Includes", pathname: "/farm-etc" } }, "/farm-etc")).toBe( + true, + ); + expect(matchPathname({ case: { type: "Includes", pathname: "/farm-etc" } }, "/farm")).toBe( + false, + ); + }); + + it("still handles Equals, Not Includes and Starts With", () => { + expect(matchPathname({ case: { type: "Equals", pathname: "/a" } }, "/a")).toBe(true); + expect(matchPathname({ case: { type: "Equals", pathname: "/a" } }, "/a/b")).toBe(false); + expect(matchPathname({ case: { type: "Not Includes", pathname: "/a" } }, "/b")).toBe(true); + expect(matchPathname({ case: { type: "Starts With", pathname: "/a" } }, "/a/b")).toBe(true); + }); + + it("still falls back to substring matching for an unknown case type", () => { + expect(matchPathname({ case: { type: "Whatever", pathname: "/farm" } }, "/x/farm/y")).toBe(true); + }); + + it("still handles the standard pattern/includes/excludes shape", () => { + expect(matchPathname({ pattern: "^/p/" }, "/p/x")).toBe(true); + expect(matchPathname({ includes: ["/a/*"] }, "/a/b")).toBe(true); + expect(matchPathname({ includes: ["/a/*"], excludes: ["/a/b"] }, "/a/b")).toBe(false); + expect(matchPathname({}, "/a")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// queryStringMatcher — CMS `conditions[]` shape +// --------------------------------------------------------------------------- + +function matchQuery(rule: Record, url: string): boolean { + return evaluateMatcher({ ...rule, __resolveType: QUERYSTRING_KEY }, { url }); +} + +describe("queryStringMatcher — CMS conditions shape", () => { + it("matches the exact rule the CMS emits for brand=farmetc", () => { + const rule = { + conditions: [{ case: { type: "Equals", value: "farmetc" }, param: "brand" }], + }; + expect(matchQuery(rule, "https://www.farmrio.com.br/vestido/p?brand=farmetc")).toBe(true); + expect(matchQuery(rule, "https://www.farmrio.com.br/vestido/p?brand=farm")).toBe(false); + expect(matchQuery(rule, "https://www.farmrio.com.br/vestido/p")).toBe(false); + }); + + it("ANDs every condition in the array", () => { + const rule = { + conditions: [ + { case: { type: "Equals", value: "farmetc" }, param: "brand" }, + { case: { type: "Equals", value: "1" }, param: "preview" }, + ], + }; + expect(matchQuery(rule, "https://x.com/p?brand=farmetc&preview=1")).toBe(true); + expect(matchQuery(rule, "https://x.com/p?brand=farmetc")).toBe(false); + }); + + it("handles Not Equals, including an absent param", () => { + const rule = { conditions: [{ case: { type: "Not Equals", value: "farm" }, param: "brand" }] }; + expect(matchQuery(rule, "https://x.com/p?brand=farmetc")).toBe(true); + expect(matchQuery(rule, "https://x.com/p")).toBe(true); + expect(matchQuery(rule, "https://x.com/p?brand=farm")).toBe(false); + }); + + it("handles Includes and Not Includes", () => { + const includes = { conditions: [{ case: { type: "Includes", value: "etc" }, param: "brand" }] }; + expect(matchQuery(includes, "https://x.com/p?brand=farmetc")).toBe(true); + expect(matchQuery(includes, "https://x.com/p?brand=farm")).toBe(false); + expect(matchQuery(includes, "https://x.com/p")).toBe(false); + + const notIncludes = { + conditions: [{ case: { type: "Not Includes", value: "etc" }, param: "brand" }], + }; + expect(notIncludes && matchQuery(notIncludes, "https://x.com/p?brand=farm")).toBe(true); + expect(matchQuery(notIncludes, "https://x.com/p?brand=farmetc")).toBe(false); + }); + + it("matches when any value of a repeated param satisfies a positive condition", () => { + const rule = { conditions: [{ case: { type: "Equals", value: "b" }, param: "x" }] }; + expect(matchQuery(rule, "https://x.com/p?x=a&x=b")).toBe(true); + const negated = { conditions: [{ case: { type: "Not Equals", value: "b" }, param: "x" }] }; + expect(matchQuery(negated, "https://x.com/p?x=a&x=b")).toBe(false); + }); + + it("accepts a top-level case + param (single-condition CMS shape)", () => { + expect( + matchQuery({ param: "brand", case: { type: "Equals", value: "farmetc" } }, "https://x.com/p?brand=farmetc"), + ).toBe(true); + }); + + it("returns false for an empty or param-less conditions array", () => { + expect(matchQuery({ conditions: [] }, "https://x.com/p?brand=farmetc")).toBe(false); + expect( + matchQuery({ conditions: [{ case: { type: "Equals", value: "farmetc" } }] }, "https://x.com/p?brand=farmetc"), + ).toBe(false); + }); + + // --- regressions: the flat { key, value } shape must be untouched --- + + it("still handles the flat key/value shape", () => { + expect(matchQuery({ key: "brand", value: "farm" }, "https://x.com/p?brand=farm")).toBe(true); + expect(matchQuery({ key: "brand", value: "farm" }, "https://x.com/p?brand=etc")).toBe(false); + expect(matchQuery({ param: "brand", value: "farm" }, "https://x.com/p?brand=farm")).toBe(true); + }); + + it("still treats a missing value as a presence check", () => { + expect(matchQuery({ key: "brand" }, "https://x.com/p?brand=anything")).toBe(true); + expect(matchQuery({ key: "brand" }, "https://x.com/p?other=1")).toBe(false); + }); + + it("still returns false with no key and with no url", () => { + expect(matchQuery({ value: "farm" }, "https://x.com/p?brand=farm")).toBe(false); + expect(evaluateMatcher({ __resolveType: QUERYSTRING_KEY, key: "brand" }, {})).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// The ETC Segment block, end to end: Includes OR (Template AND queryString) +// --------------------------------------------------------------------------- + +describe("ETC Segment — multi matcher over the real rule shapes", () => { + const etcSegment = { + __resolveType: "website/matchers/multi.ts", + op: "or", + matchers: [ + { + __resolveType: PATHNAME_KEY, + case: { type: "Includes", pathname: "/farm-etc" }, + }, + { + __resolveType: "website/matchers/multi.ts", + op: "and", + matchers: [ + { __resolveType: PATHNAME_KEY, case: { type: "Template", pathname: "/:slug/p" } }, + { + __resolveType: QUERYSTRING_KEY, + conditions: [{ case: { type: "Equals", value: "farmetc" }, param: "brand" }], + }, + ], + }, + ], + }; + + function atUrl(url: string): boolean { + return evaluateMatcher(etcSegment, { url, path: new URL(url).pathname }); + } + + it("matches the category page (first branch — worked before the fix)", () => { + expect(atUrl("https://www.farmrio.com.br/farm-etc")).toBe(true); + }); + + it("matches an ETC PDP (second branch — the branch that never fired)", () => { + expect(atUrl("https://www.farmrio.com.br/vestido-longo/p?brand=farmetc")).toBe(true); + }); + + it("does not match a FARM PDP", () => { + expect(atUrl("https://www.farmrio.com.br/vestido-longo/p?brand=farm")).toBe(false); + expect(atUrl("https://www.farmrio.com.br/vestido-longo/p")).toBe(false); + }); + + it("does not match an unrelated page", () => { + expect(atUrl("https://www.farmrio.com.br/vestidos")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Matcher-override contract (#4) +// --------------------------------------------------------------------------- + +describe("matcher override contract", () => { + const OVERRIDE_KEY = "website/matchers/host.ts"; + + afterEach(() => { + // Drop the site override so the framework can reclaim the key. + unregisterMatcher(OVERRIDE_KEY); + registerBuiltinMatchers(); + }); + + it("keeps a site override registered BEFORE registerBuiltinMatchers()", () => { + registerMatcher(OVERRIDE_KEY, () => true); + registerBuiltinMatchers(); + // The builtin host matcher returns false without a `host` prop. + expect(evaluateMatcher({ __resolveType: OVERRIDE_KEY }, {})).toBe(true); + }); + + it("keeps a site override registered AFTER registerBuiltinMatchers()", () => { + registerBuiltinMatchers(); + registerMatcher(OVERRIDE_KEY, () => true); + expect(evaluateMatcher({ __resolveType: OVERRIDE_KEY }, {})).toBe(true); + }); + + it("survives createSiteSetup() regardless of registration order", () => { + registerMatcher(OVERRIDE_KEY, () => true); + createSiteSetup({ sections: {}, blocks: {} }); + expect(evaluateMatcher({ __resolveType: OVERRIDE_KEY }, {})).toBe(true); + + // ...and a later override still wins over the builtins createSiteSetup registered. + registerMatcher(OVERRIDE_KEY, () => false); + expect(evaluateMatcher({ __resolveType: OVERRIDE_KEY }, {})).toBe(false); + }); + + it("still lets registerBuiltinMatchers() replace resolve.ts's inline builtin", () => { + // resolve.ts registers an inclusive [start, end] date matcher at module + // load; builtins.ts registers the strict-inequality one. Framework-owned + // keys stay overwritable — only *site* registrations are protected. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-07T12:00:00Z")); + try { + registerBuiltinMatchers(); + expect(evaluateMatcher({ __resolveType: DATE_KEY, start: "2026-07-07T12:00:00Z" }, {})).toBe( + false, + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/blocks/src/matchers/builtins.ts b/packages/blocks/src/matchers/builtins.ts index 9fb48980..f13096d0 100644 --- a/packages/blocks/src/matchers/builtins.ts +++ b/packages/blocks/src/matchers/builtins.ts @@ -115,10 +115,96 @@ function isSafePattern(pattern: string): boolean { return true; } +// --- Route-template matching (CMS `type: "Template"`) -------------------- +// +// The CMS emits route syntax (`/:slug/p`, `/*/p`) under `type: "Template"`. +// Before this was handled, `Template` fell through to the `Includes` default +// and tested `path.includes("/:slug/p")` — never true, so every PDP-scoped +// variant silently lost to its fallback. +// +// `URLPattern` is native in workerd and Node >= 23. Older Node (the +// @decocms/nextjs target) has no global, so we compile an equivalent regex +// rather than returning false and reintroducing the same silent miss. +// +// Compilation is cached: the pattern set per site is tiny and bounded, but +// recompiling per request measured ~6% of CPU on farmrio. + +const PATTERN_CACHE_MAX = 100; +type PathTest = (path: string) => boolean; + +/** Compiled-pattern LRU. A `null` value memoizes "this pattern is invalid". */ +const patternCache = new Map(); + +interface URLPatternLike { + test(input: { pathname: string }): boolean; +} +type URLPatternCtorLike = new (init: { pathname: string }) => URLPatternLike; + +function compileRouteTemplate(pattern: string): PathTest | null { + if (!isSafePattern(pattern)) return null; + + const URLPatternCtor = (globalThis as { URLPattern?: URLPatternCtorLike }).URLPattern; + if (URLPatternCtor) { + try { + const compiled = new URLPatternCtor({ pathname: pattern }); + return (path: string) => { + try { + return compiled.test({ pathname: path }); + } catch { + return false; + } + }; + } catch { + return null; + } + } + + // Fallback: `:param` -> one path segment, `*` -> anything, everything else + // literal. Covers the route syntax the CMS actually emits; modifiers + // (`:p?`, `{...}` groups) are not supported here. + try { + let source = ""; + for (const part of pattern.split(/(:[A-Za-z0-9_]+|\*)/)) { + if (!part) continue; + if (part === "*") source += ".*"; + else if (part.startsWith(":")) source += "[^/]+"; + else source += part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + const regex = new RegExp(`^${source}$`); + return (path: string) => regex.test(path); + } catch { + return null; + } +} + +function getRouteTemplateTest(pattern: string): PathTest | null { + const cached = patternCache.get(pattern); + if (cached !== undefined) { + // Touch for LRU recency. + patternCache.delete(pattern); + patternCache.set(pattern, cached); + return cached; + } + + const compiled = compileRouteTemplate(pattern); + if (patternCache.size >= PATTERN_CACHE_MAX) { + const oldest = patternCache.keys().next().value; + if (oldest !== undefined) patternCache.delete(oldest); + } + patternCache.set(pattern, compiled); + return compiled; +} + +/** @internal test-only */ +export function __resetPathnamePatternCache(): void { + patternCache.clear(); +} + function pathnameMatcher(rule: Record, ctx: MatcherContext): boolean { const path = ctx.path ?? ""; - // CMS "case" format: { type: "Includes" | "Equals" | "Not Includes" | "Starts With", pathname: "/..." } + // CMS "case" format: + // { type: "Includes" | "Equals" | "Not Includes" | "Starts With" | "Template", pathname: "/..." } const caseObj = rule.case as { type?: string; pathname?: string } | undefined; if (caseObj?.pathname) { switch (caseObj.type) { @@ -128,6 +214,10 @@ function pathnameMatcher(rule: Record, ctx: MatcherContext): bo return !path.includes(caseObj.pathname); case "Starts With": return path.startsWith(caseObj.pathname); + case "Template": { + const test = getRouteTemplateTest(caseObj.pathname); + return test ? test(path) : false; + } case "Includes": default: return path.includes(caseObj.pathname); @@ -186,25 +276,93 @@ function pathnameMatcher(rule: Record, ctx: MatcherContext): bo // Query string matcher // ------------------------------------------------------------------------- -function queryStringMatcher(rule: Record, ctx: MatcherContext): boolean { - const key = (rule.key ?? rule.param) as string | undefined; - const value = rule.value as string | undefined; +interface QueryCase { + type?: string; + value?: string; +} - if (!key) return false; +interface QueryCondition { + param: string; + case: QueryCase; +} +/** + * The CMS emits `{ conditions: [{ param: "brand", case: { type: "Equals", + * value: "farmetc" } }] }` — the param name sits *beside* `case` and the + * value *inside* it. The flat `{ key, value }` shape is the older hand-written + * form and is still accepted. + * + * Reading only the flat shape meant `key` was always `undefined` for + * CMS-authored rules, so the matcher returned false unconditionally. + */ +function normalizeQueryConditions(rule: Record): QueryCondition[] { + const raw = rule.conditions; + if (Array.isArray(raw)) { + const out: QueryCondition[] = []; + for (const entry of raw as Array>) { + if (!entry) continue; + const param = (entry.param ?? entry.key) as string | undefined; + if (!param) continue; + const caseObj = entry.case as QueryCase | undefined; + out.push({ + param, + case: caseObj ?? { type: "Equals", value: entry.value as string | undefined }, + }); + } + return out; + } + + const param = (rule.key ?? rule.param) as string | undefined; + if (!param) return []; + const caseObj = rule.case as QueryCase | undefined; + return [{ param, case: caseObj ?? { type: "Equals", value: rule.value as string | undefined } }]; +} + +/** + * Evaluate one condition against the request's query string. + * + * A repeated param (`?a=1&a=2`) satisfies a positive condition when *any* + * value matches, and a negated one when *no* value matches — which also makes + * an absent param satisfy "Not Equals" / "Not Includes". + */ +function matchQueryCondition(params: URLSearchParams, condition: QueryCondition): boolean { + const { param } = condition; + const { type, value } = condition.case; + const values = params.getAll(param); + + switch (type) { + case "Not Equals": + return !values.some((v) => v === value); + case "Not Includes": + return !values.some((v) => v.includes(value ?? "")); + case "Includes": + return values.some((v) => v.includes(value ?? "")); + case "Equals": + default: + // No value configured — presence check (pre-existing behavior). + if (value === undefined) return values.length > 0; + return values.some((v) => v === value); + } +} + +function queryStringMatcher(rule: Record, ctx: MatcherContext): boolean { const currentUrl = ctx.url; if (!currentUrl) return false; + let params: URLSearchParams; try { - const url = new URL(currentUrl); - const paramValue = url.searchParams.get(key); - - if (paramValue === null) return false; - if (value === undefined) return true; - return paramValue === value; + // Base makes a path-relative ctx.url (some bindings pass one) parse + // instead of throwing straight to `false`. + params = new URL(currentUrl, "http://localhost").searchParams; } catch { return false; } + + const conditions = normalizeQueryConditions(rule); + if (conditions.length === 0) return false; + + // Every condition must hold — the CMS renders `conditions[]` as an AND. + return conditions.every((condition) => matchQueryCondition(params, condition)); } // ------------------------------------------------------------------------- @@ -440,20 +598,25 @@ function negateMatcher(rule: Record, ctx: MatcherContext): bool /** * Register all built-in matchers with the CMS resolver. * - * Call once during app setup (in setup.ts or similar). - * These cover the matchers from deco-cx/apps that weren't - * handled inline in resolve.ts. + * Called for you by `createSiteSetup()`. Every registration here is marked + * `builtin`, which means **it never overwrites a matcher a site registered + * for the same key** — a site's `registerMatcher("website/matchers/host.ts", + * mine)` wins whether it runs before or after `createSiteSetup()`. Previously + * this function overwrote unconditionally, so whether an override survived + * depended on module evaluation order in the bundle: it could work in + * `vite dev` and be lost in the production build of the same site. */ export function registerBuiltinMatchers(): void { - registerMatcher("website/matchers/cookie.ts", cookieMatcher); - registerMatcher("website/matchers/cron.ts", cronMatcher); - registerMatcher("website/matchers/date.ts", dateMatcher); - registerMatcher("website/matchers/host.ts", hostMatcher); - registerMatcher("website/matchers/pathname.ts", pathnameMatcher); - registerMatcher("website/matchers/queryString.ts", queryStringMatcher); - registerMatcher("website/matchers/location.ts", locationMatcher); - registerMatcher("website/matchers/userAgent.ts", userAgentMatcher); - registerMatcher("website/matchers/environment.ts", environmentMatcher); - registerMatcher("website/matchers/multi.ts", multiMatcher); - registerMatcher("website/matchers/negate.ts", negateMatcher); + const builtin = { builtin: true } as const; + registerMatcher("website/matchers/cookie.ts", cookieMatcher, builtin); + registerMatcher("website/matchers/cron.ts", cronMatcher, builtin); + registerMatcher("website/matchers/date.ts", dateMatcher, builtin); + registerMatcher("website/matchers/host.ts", hostMatcher, builtin); + registerMatcher("website/matchers/pathname.ts", pathnameMatcher, builtin); + registerMatcher("website/matchers/queryString.ts", queryStringMatcher, builtin); + registerMatcher("website/matchers/location.ts", locationMatcher, builtin); + registerMatcher("website/matchers/userAgent.ts", userAgentMatcher, builtin); + registerMatcher("website/matchers/environment.ts", environmentMatcher, builtin); + registerMatcher("website/matchers/multi.ts", multiMatcher, builtin); + registerMatcher("website/matchers/negate.ts", negateMatcher, builtin); } diff --git a/packages/blocks/src/sdk/index.ts b/packages/blocks/src/sdk/index.ts index 36babe3c..7df1679d 100644 --- a/packages/blocks/src/sdk/index.ts +++ b/packages/blocks/src/sdk/index.ts @@ -64,6 +64,7 @@ export { canonicalUrl, cleanPathForCacheKey, hasTrackingParams, + isTrackingParam, registerTrackingParam, registerTrackingParams, stripTrackingParams, diff --git a/packages/blocks/src/sdk/urlUtils.ts b/packages/blocks/src/sdk/urlUtils.ts index 37dc565b..2da219cd 100644 --- a/packages/blocks/src/sdk/urlUtils.ts +++ b/packages/blocks/src/sdk/urlUtils.ts @@ -44,6 +44,16 @@ export function registerTrackingParams(params: string[]): void { } } +/** + * Whether a single query param is a known tracking/attribution param. + * + * Same registry as {@link stripTrackingParams} — use this when building a + * cache key by hand so `utm_*` variants don't fragment the cache. + */ +export function isTrackingParam(param: string): boolean { + return UTM_PARAMS.has(param.toLowerCase()); +} + /** * Strip UTM and tracking parameters from a URL. * diff --git a/packages/tanstack/src/routes/cmsRoute.ts b/packages/tanstack/src/routes/cmsRoute.ts index 4a2a3a77..763e033f 100644 --- a/packages/tanstack/src/routes/cmsRoute.ts +++ b/packages/tanstack/src/routes/cmsRoute.ts @@ -202,7 +202,7 @@ async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) { // empty client-bundled `blocks.gen.ts`. const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request), - resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS), + resolveGlobals ? resolveSiteGlobals(matcherCtx) : Promise.resolve(EMPTY_GLOBALS), ]); // Page sections take precedence over globals — dedupe drops any global @@ -332,7 +332,7 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }) const flags = persistFlags(matcherCtx); const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request), - resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS), + resolveGlobals ? resolveSiteGlobals(matcherCtx) : Promise.resolve(EMPTY_GLOBALS), ]); const mergedSections: ResolvedSection[] = [ diff --git a/packages/tanstack/src/routes/withSiteGlobals.test.ts b/packages/tanstack/src/routes/withSiteGlobals.test.ts index b8d07fd9..ee0893a1 100644 --- a/packages/tanstack/src/routes/withSiteGlobals.test.ts +++ b/packages/tanstack/src/routes/withSiteGlobals.test.ts @@ -17,6 +17,7 @@ import { __resetSiteGlobalsCache, dedupeGlobals, resolveSiteGlobals, + siteGlobalsCacheKey, withSiteGlobals, } from "./withSiteGlobals"; @@ -156,6 +157,168 @@ describe("withSiteGlobals", () => { }); }); + // ------------------------------------------------------------------------- + // Matcher context (#3) + // + // Global sections can hold URL-dependent variants — the multivariate + // Alerta/topbar block lives in `site.global`. Resolving without a matcher + // context, or caching by path alone, silently collapses those variants. + // ------------------------------------------------------------------------- + + describe("matcher context", () => { + function siteWithGlobal() { + mockedLoadBlocks.mockReturnValue({ + site: { global: [{ __resolveType: "Alerta" }] }, + }); + mockedResolvePageSections.mockResolvedValue([{ component: "Alerta.tsx", props: {}, key: "k0" }]); + } + + const farmCtx = { + path: "/vestido/p", + url: "https://www.farmrio.com.br/vestido/p?brand=farm", + }; + const etcCtx = { + path: "/vestido/p", + url: "https://www.farmrio.com.br/vestido/p?brand=farmetc", + }; + + it("forwards the matcher context to resolvePageSections", async () => { + siteWithGlobal(); + await resolveSiteGlobals(etcCtx); + expect(mockedResolvePageSections).toHaveBeenCalledWith( + [{ __resolveType: "Alerta" }], + etcCtx, + ); + }); + + it("does NOT share a cache entry between same path + different query", async () => { + siteWithGlobal(); + + // Cold path, ETC first — pre-fix this poisoned the entry for both URLs. + await resolveSiteGlobals(etcCtx); + await resolveSiteGlobals(farmCtx); + + expect(mockedResolvePageSections).toHaveBeenCalledTimes(2); + expect(mockedResolvePageSections).toHaveBeenNthCalledWith(1, expect.anything(), etcCtx); + expect(mockedResolvePageSections).toHaveBeenNthCalledWith(2, expect.anything(), farmCtx); + }); + + it("shares one cache entry for the identical path + query", async () => { + siteWithGlobal(); + await resolveSiteGlobals(etcCtx); + await resolveSiteGlobals({ ...etcCtx }); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(1); + }); + + it("treats param order as irrelevant (sorted key)", async () => { + siteWithGlobal(); + await resolveSiteGlobals({ path: "/p", url: "https://x.com/p?a=1&b=2" }); + await resolveSiteGlobals({ path: "/p", url: "https://x.com/p?b=2&a=1" }); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(1); + }); + + it("does not fragment the cache across utm_* variants", async () => { + siteWithGlobal(); + await resolveSiteGlobals({ path: "/p", url: "https://x.com/p?brand=farmetc" }); + await resolveSiteGlobals({ + path: "/p", + url: "https://x.com/p?brand=farmetc&utm_source=google&gclid=xyz", + }); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(1); + }); + + it("separates different paths", async () => { + siteWithGlobal(); + await resolveSiteGlobals({ path: "/a", url: "https://x.com/a" }); + await resolveSiteGlobals({ path: "/b", url: "https://x.com/b" }); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(2); + }); + + it("keeps the no-context call on its own key (back-compat)", async () => { + siteWithGlobal(); + await resolveSiteGlobals(); + await resolveSiteGlobals(); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(1); + expect(mockedResolvePageSections).toHaveBeenCalledWith([{ __resolveType: "Alerta" }], undefined); + }); + + it("dedups concurrent in-flight calls per key, not globally", async () => { + mockedLoadBlocks.mockReturnValue({ + site: { global: [{ __resolveType: "Alerta" }] }, + }); + mockedResolvePageSections.mockImplementation(async (_refs: unknown, ctx: any) => [ + { component: "Alerta.tsx", props: { brand: new URL(ctx.url).searchParams.get("brand") }, key: "k0" }, + ]); + + const [etc, farm, etcAgain] = await Promise.all([ + resolveSiteGlobals(etcCtx), + resolveSiteGlobals(farmCtx), + resolveSiteGlobals(etcCtx), + ]); + + expect(etc.resolvedSections[0].props).toEqual({ brand: "farmetc" }); + expect(farm.resolvedSections[0].props).toEqual({ brand: "farm" }); + expect(etcAgain).toBe(etc); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(2); + }); + + it("evicts old keys instead of growing without bound", async () => { + siteWithGlobal(); + // CACHE_MAX_ENTRIES is 64 — walk well past it, then re-request the first. + for (let i = 0; i < 80; i++) { + await resolveSiteGlobals({ path: "/p", url: `https://x.com/p?i=${i}` }); + } + expect(mockedResolvePageSections).toHaveBeenCalledTimes(80); + + await resolveSiteGlobals({ path: "/p", url: "https://x.com/p?i=0" }); + expect(mockedResolvePageSections).toHaveBeenCalledTimes(81); // key 0 was evicted + }); + }); + + describe("siteGlobalsCacheKey", () => { + it("is empty for no context", () => { + expect(siteGlobalsCacheKey()).toBe(""); + }); + + it("starts with the path alone when there is no query", () => { + expect(siteGlobalsCacheKey({ path: "/p", url: "https://x.com/p" })).toBe("/p|desktop|"); + expect(siteGlobalsCacheKey({ path: "/p" })).toBe("/p|desktop|"); + }); + + it("includes sorted, tracking-free query params", () => { + expect(siteGlobalsCacheKey({ path: "/p", url: "https://x.com/p?b=2&utm_source=g&a=1" })).toBe( + "/p?a=1&b=2|desktop|", + ); + }); + + it("distinguishes brand=farm from brand=farmetc", () => { + expect(siteGlobalsCacheKey({ path: "/x/p", url: "https://x.com/x/p?brand=farm" })).not.toBe( + siteGlobalsCacheKey({ path: "/x/p", url: "https://x.com/x/p?brand=farmetc" }), + ); + }); + + it("distinguishes device class", () => { + const mobile = siteGlobalsCacheKey({ + path: "/p", + url: "https://x.com/p", + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148", + }); + expect(mobile).not.toBe(siteGlobalsCacheKey({ path: "/p", url: "https://x.com/p" })); + }); + + it("distinguishes the sticky-flag segment cohort", () => { + expect( + siteGlobalsCacheKey({ path: "/p", url: "https://x.com/p", cookies: { deco_segment: "a" } }), + ).not.toBe( + siteGlobalsCacheKey({ path: "/p", url: "https://x.com/p", cookies: { deco_segment: "b" } }), + ); + }); + + it("falls back to the path for an unparseable url", () => { + expect(siteGlobalsCacheKey({ path: "/p", url: "::::" })).toBe("/p|desktop|"); + }); + }); + describe("withSiteGlobals (deprecated no-op)", () => { // Site globals merging moved into the `loadCmsPage` server function so SSR // and SPA navigations both go through the same server-side path (#233). diff --git a/packages/tanstack/src/routes/withSiteGlobals.ts b/packages/tanstack/src/routes/withSiteGlobals.ts index d89b53c8..9f5b6b82 100644 --- a/packages/tanstack/src/routes/withSiteGlobals.ts +++ b/packages/tanstack/src/routes/withSiteGlobals.ts @@ -25,8 +25,11 @@ * ``` */ -import type { ResolvedSection } from "@decocms/blocks/cms"; +import type { MatcherContext, ResolvedSection } from "@decocms/blocks/cms"; import { loadBlocks, onChange, resolvePageSections } from "@decocms/blocks/cms"; +import { SEGMENT_COOKIE } from "@decocms/blocks/sdk/flags"; +import { isTrackingParam } from "@decocms/blocks/sdk/urlUtils"; +import { detectDevice } from "@decocms/blocks/sdk/useDevice"; // --------------------------------------------------------------------------- // Types @@ -73,14 +76,89 @@ interface CacheEntry { const DEFAULT_CACHE_TTL_MS = 5 * 60_000; const cacheTtlMs = DEFAULT_CACHE_TTL_MS; -let cache: CacheEntry | null = null; -let inflight: Promise | null = null; +/** + * Cap on distinct cache keys held per isolate. Globals now vary by URL, so an + * unbounded map would grow with the site's URL space (and with any attacker's + * junk query strings). LRU-evict the oldest key past this. + */ +const CACHE_MAX_ENTRIES = 64; + +const cache = new Map(); +const inflight = new Map>(); + +/** `undefined` = not looked up yet, `null` = this site declares no globals. */ +let refsMemo: SiteGlobalRef[] | null | undefined; onChange(() => { - cache = null; - inflight = null; + cache.clear(); + inflight.clear(); + refsMemo = undefined; }); +function urlKeyPart(matcherCtx: MatcherContext): string { + const path = matcherCtx.path ?? ""; + if (!matcherCtx.url) return path; + try { + const params = [...new URL(matcherCtx.url, "http://localhost").searchParams] + .filter(([key]) => !isTrackingParam(key)) + .sort(([aKey, aVal], [bKey, bVal]) => aKey.localeCompare(bKey) || aVal.localeCompare(bVal)); + if (params.length === 0) return path; + return `${path}?${params.map(([key, value]) => `${key}=${value}`).join("&")}`; + } catch { + return path; + } +} + +/** + * Cache key for one request's site globals. + * + * **The query string must be part of it.** Site globals can hold + * URL-dependent variants (the multivariate Alerta/topbar section lives in + * `site.global`), and a path-only key made `/x/p?brand=farm` and + * `/x/p?brand=farmetc` collide: whichever request warmed a cold path decided + * the variant for both. Params are sorted so the key is stable, and tracking + * params are dropped so `utm_*` traffic doesn't fragment the cache into one + * entry per campaign. + * + * Device class and the sticky-flag segment cookie are in the key for the same + * reason — device and A/B matchers are common in `site.global`, and both are + * already how the edge splits its own cache. + * + * **Known limit:** matchers reading *other* cookies, geo, or wall-clock time + * are not represented here, so two requests that differ only on one of those + * share an entry for up to the TTL. Cookie/geo-varying globals need a wider + * key (or no cache) — this covers the axes the CMS actually exposes on the + * multivariate section today. + */ +export function siteGlobalsCacheKey(matcherCtx?: MatcherContext): string { + if (!matcherCtx) return ""; + const device = detectDevice(matcherCtx.userAgent ?? ""); + const segment = matcherCtx.cookies?.[SEGMENT_COOKIE] ?? ""; + return `${urlKeyPart(matcherCtx)}|${device}|${segment}`; +} + +function readCache(key: string): CacheEntry | null { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + cache.delete(key); + return null; + } + // Touch for LRU recency. + cache.delete(key); + cache.set(key, entry); + return entry; +} + +function writeCache(key: string, entry: CacheEntry): void { + cache.delete(key); + if (cache.size >= CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, entry); +} + function readSiteBlock(): SiteBlock | null { const blocks = loadBlocks(); // Block keys vary by site convention — try both common cases. @@ -96,6 +174,20 @@ function gatherSectionRefs(site: SiteBlock): SiteGlobalRef[] { return refs; } +/** + * The raw refs are the same for every request — only their *resolution* varies + * by matcher context. Memoizing them separately from the per-key resolution + * cache keeps the "no Site block / no globals" fast path from consuming a + * cache slot per URL. Cleared by the same `onChange` invalidation. + */ +function siteGlobalRefs(): SiteGlobalRef[] | null { + if (refsMemo !== undefined) return refsMemo; + const site = readSiteBlock(); + const refs = site ? gatherSectionRefs(site) : []; + refsMemo = refs.length > 0 ? refs : null; + return refsMemo; +} + const EMPTY_ENTRY: CacheEntry = { resolvedSections: [], rawRefs: [], @@ -109,52 +201,52 @@ const EMPTY_ENTRY: CacheEntry = { * Cache is invalidated by `onChange()` from the CMS loader, so admin edits * and decofile reloads are reflected on the next request. * + * Pass the request's `matcherCtx` so global sections can carry URL-, date- + * and cookie-dependent variants — without it every matcher in `site.global` + * evaluates against an empty context, and multivariate globals (the Alerta / + * topbar block) silently collapse to their fallback variant. Results are + * cached per {@link siteGlobalsCacheKey}, i.e. per path **and** query string. + * * Exposed as a util so sites can call it directly if they need globals * outside the route loader path (rare). */ -export async function resolveSiteGlobals(): Promise<{ +export async function resolveSiteGlobals(matcherCtx?: MatcherContext): Promise<{ resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[]; }> { - const now = Date.now(); - if (cache && cache.expiresAt > now) return cache; - if (inflight) return inflight; + // No Site block, or no globals declared on it — context-independent, so it + // short-circuits ahead of the per-key cache. + const rawRefs = siteGlobalRefs(); + if (!rawRefs) return EMPTY_ENTRY; - const site = readSiteBlock(); - if (!site) { - // Cache the empty result so subsequent requests don't re-walk the - // block registry. `onChange` invalidation still applies — if a Site - // block appears later, the listener nulls `cache` and we re-check. - cache = EMPTY_ENTRY; - return EMPTY_ENTRY; - } + const key = siteGlobalsCacheKey(matcherCtx); - const rawRefs = gatherSectionRefs(site); - if (rawRefs.length === 0) { - cache = EMPTY_ENTRY; - return EMPTY_ENTRY; - } + const cached = readCache(key); + if (cached) return cached; + const pending = inflight.get(key); + if (pending) return pending; - inflight = (async () => { + const promise = (async () => { try { - const resolvedSections = await resolvePageSections(rawRefs); + const resolvedSections = await resolvePageSections(rawRefs, matcherCtx); const entry: CacheEntry = { resolvedSections, rawRefs, expiresAt: Date.now() + cacheTtlMs, }; - cache = entry; + writeCache(key, entry); return entry; } catch (err) { console.error("[site-globals] failed to resolve:", err); // Don't cache failures — let the next request retry. return { resolvedSections: [], rawRefs, expiresAt: 0 }; } finally { - inflight = null; + inflight.delete(key); } })(); - return inflight; + inflight.set(key, promise); + return promise; } // --------------------------------------------------------------------------- @@ -217,6 +309,7 @@ export function withSiteGlobals(routeConfig: T) /** @internal */ export function __resetSiteGlobalsCache() { - cache = null; - inflight = null; + cache.clear(); + inflight.clear(); + refsMemo = undefined; }