diff --git a/ark/schema/__tests__/scope.test.ts b/ark/schema/__tests__/scope.test.ts index a64d736991..dc6b2a2bf2 100644 --- a/ark/schema/__tests__/scope.test.ts +++ b/ark/schema/__tests__/scope.test.ts @@ -87,4 +87,11 @@ contextualize(() => { "a.b.a.b must be an object (was a string)" ) }) + + it("allows multiple scopes with the same name without collision", () => { + const s1 = schemaScope({ a: { domain: "string" } }, { name: "Array" }) + const s2 = schemaScope({ b: { domain: "number" } }, { name: "Array" }) + attest(s1.name).equals("Array") + attest(s2.name).equals("Array") + }) }) diff --git a/ark/schema/scope.ts b/ark/schema/scope.ts index 13f5ce0131..324c1e7c04 100644 --- a/ark/schema/scope.ts +++ b/ark/schema/scope.ts @@ -150,7 +150,9 @@ export type writeDuplicateAliasError = export type AliasDefEntry = [name: string, defValue: unknown] -const scopesByName: Record = {} +// Fallback counter for anonymous scopes; scopes are identified by instance +// identity rather than names to support HMR and multi-bundle environments. +let anonymousScopeCount = 0 export type GlobalOnlyConfigOptionName = satisfy< keyof ArkSchemaConfig, @@ -274,11 +276,7 @@ export abstract class BaseScope<$ extends {} = {}> { this.resolvedConfig = mergeConfigs($ark.resolvedConfig, config) this.name = - this.resolvedConfig.name ?? - `anonymousScope${Object.keys(scopesByName).length}` - if (this.name in scopesByName) - throwParseError(`A Scope already named ${this.name} already exists`) - scopesByName[this.name] = this + this.resolvedConfig.name ?? `anonymousScope${anonymousScopeCount++}` const aliasEntries = Object.entries(def).map(entry => this.preparseOwnAliasEntry(...entry) diff --git a/ark/type/__tests__/scope.test.ts b/ark/type/__tests__/scope.test.ts index 1fb748b18d..01555fb34c 100644 --- a/ark/type/__tests__/scope.test.ts +++ b/ark/type/__tests__/scope.test.ts @@ -527,4 +527,17 @@ b.c.c must be an object (was missing)`) attest(types.foo.json).snap({ domain: "string" }) attest(types.bar.json).snap({ domain: "number" }) }) + + it("allows multiple scopes with the same name without collision", () => { + const s1 = scope({ a: "string" }, { name: "Array" }) + const s2 = scope({ a: "number" }, { name: "Array" }) + attest((s1 as any).name).equals("Array") + attest((s2 as any).name).equals("Array") + + // Verify parseCache does not cross-pollinate across scopes with the same name + const t1 = s1.type("a") + const t2 = s2.type("a") + attest(t1.expression).equals("string") + attest(t2.expression).equals("number") + }) }) diff --git a/ark/type/parser/definition.ts b/ark/type/parser/definition.ts index 8c9697e0dd..e5d80f6e32 100644 --- a/ark/type/parser/definition.ts +++ b/ark/type/parser/definition.ts @@ -2,6 +2,7 @@ import { hasArkKind, type BaseParseContext, type BaseRoot, + type BaseScope, type StandardSchemaV1 } from "@ark/schema" import { @@ -52,9 +53,7 @@ import { type validateTupleLiteral } from "./tupleLiteral.ts" -const parseCache: { - [cacheId: string]: { [def: string]: InnerParseResult } | undefined -} = {} +const parseCache = new WeakMap>() export const parseInnerDefinition = ( def: unknown, @@ -66,7 +65,11 @@ export const parseInnerDefinition = ( // resolutions like "this" or generic args return parseString(def, ctx) } - const scopeCache = (parseCache[ctx.$.name] ??= {}) + let scopeCache = parseCache.get(ctx.$) + if (!scopeCache) { + scopeCache = {} + parseCache.set(ctx.$, scopeCache) + } return (scopeCache[def] ??= parseString(def, ctx)) } return hasDomain(def, "object") ?