Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions packages/blocks/src/cms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export {
setDanglingReferenceHandler,
setResolveErrorHandler,
unregisterCommerceLoader,
unregisterMatcher,
WELL_KNOWN_TYPES,
} from "./resolve";
export type {
Expand Down
56 changes: 52 additions & 4 deletions packages/blocks/src/cms/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,13 +545,63 @@ const customMatchers: Record<
(rule: Record<string, unknown>, 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<string> = (G.__deco.builtinMatcherKeys ??= new Set<string>());

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<string, unknown>, 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 });
}
}

Expand Down
Loading