From 8dd19339fb48b60e12d22f93708ff7560c482280 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 07:03:07 -0800 Subject: [PATCH 01/12] test(core): each shared-realm shape gives the package and a copy one value (red) --- packages/core/src/utils/sharedRealm.test.ts | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/core/src/utils/sharedRealm.test.ts diff --git a/packages/core/src/utils/sharedRealm.test.ts b/packages/core/src/utils/sharedRealm.test.ts new file mode 100644 index 0000000000000..9ca7f7812630d --- /dev/null +++ b/packages/core/src/utils/sharedRealm.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file sharedRealm.test.ts + * @input Two loads of sharedRealm.ts, as the package and a swizzled copy each + * carry one + * @output Proves each shared shape gives both loads one value: state, maps, + * stores, brands and sequences, and that the test reset forgets them + * @position Unit tests for utils/sharedRealm.ts (spec:AST-110 FR3) + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; + +type Realm = typeof import('./sharedRealm'); + +/** Two loads of the module: the package's and a copy's. */ +async function twoCopies(): Promise<[Realm, Realm]> { + const pkg = await import('./sharedRealm'); + vi.resetModules(); + const copy = await import('./sharedRealm'); + expect(copy).not.toBe(pkg); + return [pkg, copy]; +} + +afterEach(async () => { + (await import('./sharedRealm')).resetSharedStateForTest('test/'); + vi.resetModules(); +}); + +describe('shared realm shapes (spec:AST-110 FR3)', () => { + it('gives two copies one state object', async () => { + const [pkg, copy] = await twoCopies(); + const a = pkg.sharedState('test/state', 1, () => ({count: 0})); + const b = copy.sharedState('test/state', 1, () => ({count: 0})); + a.count += 1; + expect(b.count).toBe(1); + expect(copy.sharedState('test/state', 2, () => ({count: 0}))).not.toBe(a); + }); + + it('gives two copies one map, weak map and weak set', async () => { + const [pkg, copy] = await twoCopies(); + const doc = {}; + pkg.sharedMap('test/map', 1).set('a', 1); + expect(copy.sharedMap('test/map', 1).get('a')).toBe(1); + pkg.sharedWeakMap('test/weak-map', 1).set(doc, 2); + expect( + copy.sharedWeakMap('test/weak-map', 1).get(doc), + ).toBe(2); + pkg.sharedWeakSet('test/weak-set', 1).add(doc); + expect(copy.sharedWeakSet('test/weak-set', 1).has(doc)).toBe(true); + }); + + it("reaches a copy's listeners from a package set", async () => { + const [pkg, copy] = await twoCopies(); + const heard: number[] = []; + const store = copy.sharedStore('test/store', 1, 0); + store.subscribe(() => heard.push(store.get())); + pkg.sharedStore('test/store', 1, 0).set(3); + expect(heard).toEqual([3]); + }); + + it('reads a value a copy branded, and nothing on an unbranded one', async () => { + const [pkg, copy] = await twoCopies(); + const pkgBrand = pkg.sharedBrand<{size: number}>('test/brand', 1); + const copyBrand = copy.sharedBrand<{size: number}>('test/brand', 1); + const branded = {[copyBrand.key]: {size: 4}}; + expect(pkgBrand.read(branded)).toEqual({size: 4}); + expect(pkgBrand.read({size: 4})).toBeUndefined(); + expect(pkgBrand.read(null)).toBeUndefined(); + }); + + it('continues one sequence across copies', async () => { + const [pkg, copy] = await twoCopies(); + const next = pkg.sharedSequence('test/sequence', 1); + const copyNext = copy.sharedSequence('test/sequence', 1); + expect([next(), copyNext(), next()]).toEqual([1, 2, 3]); + }); + + it('forgets shared values under a prefix', async () => { + const realm = await import('./sharedRealm'); + realm.sharedState('test/reset', 1, () => ({count: 5})); + realm.resetSharedStateForTest('test/reset'); + expect(realm.sharedState('test/reset', 1, () => ({count: 0})).count).toBe( + 0, + ); + }); +}); From 6392e532da01a323748040ec24fd08247cfc72eb Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 07:11:15 -0800 Subject: [PATCH 02/12] feat(core): shared-realm helpers hold every kind of cross-module state sharedRealm.ts gains the shapes the FR3 fixes need, each a thin layer over realmValue and sharedKey: sharedState for counters, flags and host objects; sharedMap, sharedWeakMap and sharedWeakSet for registries and per-document stores; sharedStore for a value and its listeners; sharedBrand and the SharedBrand type for brands; sharedSequence for id counters; and resetSharedStateForTest. The source check asserts that every shared slot is named with a literal name and version, once. AST-110 FR3 states that cross-module state is held through these helpers. --- docs/specs/AST-110-swizzle-output/spec.md | 5 +- packages/core/src/utils/sharedRealm.ts | 136 +++++++++++++++++++++- scripts/shared-identity.mjs | 73 ++++++++++++ scripts/shared-identity.test.mjs | 25 +++- 4 files changed, 236 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-110-swizzle-output/spec.md b/docs/specs/AST-110-swizzle-output/spec.md index b2397c3af9c57..7590c1269f615 100644 --- a/docs/specs/AST-110-swizzle-output/spec.md +++ b/docs/specs/AST-110-swizzle-output/spec.md @@ -99,7 +99,10 @@ and the repository proves it before a change that could break it merges. is never shared across two Reacts in one process, so one request never reads another's provided value. A symbol that identifies objects across modules, such as a brand or a context key, is registered under the same kind of key, so a copy - and the package agree on it. The rule holds in both directions: a copy inside + and the package agree on it. Such state is held through Core's shared-realm + helpers, so a copy and the package resolve the same slot; a module-scope + registry, counter or brand outside them fails the source check unless the + allowlist names it with a reason. The rule holds in both directions: a copy inside a package overlay, and package components inside a copied Dialog or other overlay. A package component inside an open copied modal delivers its announcements inside that modal (`spec:AST-086` FR14), and package group and diff --git a/packages/core/src/utils/sharedRealm.ts b/packages/core/src/utils/sharedRealm.ts index 48fec3ea52a56..2a054307153f7 100644 --- a/packages/core/src/utils/sharedRealm.ts +++ b/packages/core/src/utils/sharedRealm.ts @@ -4,7 +4,8 @@ * @file sharedRealm.ts * @input A registered name and its protocol version * @output One value per key in the JavaScript realm, shared by every copy of - * Core + * Core, and the shapes that hold it: state, maps, stores, brands and + * sequences * @position Shared runtime state for stores and contexts; no React and no * client directive, so server-safe modules can use it */ @@ -40,3 +41,136 @@ export function realmValue( realm[key] = value; return value; } + +/** + * One value per page under a registered name: a counter, a flag, or an object + * that holds several. Every copy of Core that asks for the same name and + * version gets the same value. Name it `//` in kebab + * case, as a literal; the name is the slot's identity, so a file move keeps + * it, and a change to the value's shape bumps the version. + */ +export function sharedState( + name: string, + version: number, + create: () => T, +): T { + return realmValue(sharedKey(name, version), create); +} + +/** A map shared by every copy of Core under a registered name. */ +export function sharedMap(name: string, version: number): Map { + return sharedState(name, version, () => new Map()); +} + +/** A weak map shared by every copy of Core, such as a per-document store. */ +export function sharedWeakMap( + name: string, + version: number, +): WeakMap { + return sharedState(name, version, () => new WeakMap()); +} + +/** A weak set shared by every copy of Core, such as a set of registered values. */ +export function sharedWeakSet( + name: string, + version: number, +): WeakSet { + return sharedState(name, version, () => new WeakSet()); +} + +/** A value with listeners, in the shape `useSyncExternalStore` reads. */ +export interface SharedStore { + get: () => T; + set: (value: T) => void; + subscribe: (listener: () => void) => () => void; +} + +/** + * A value and its listeners shared by every copy of Core. A `set` from one + * copy reaches the listeners of every copy. + */ +export function sharedStore( + name: string, + version: number, + initial: T, +): SharedStore { + return sharedState(name, version, () => { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set: next => { + if (Object.is(next, value)) { + return; + } + value = next; + for (const listener of [...listeners]) { + listener(); + } + }, + subscribe: listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + }); +} + +/** + * The type side of a shared brand: one optional member that is never present, + * so the branded types of the package and a swizzled copy match. + */ +export type SharedBrand = { + readonly [K in `@astryxdesign/core/${Name}`]?: never; +}; + +/** Marks and reads a value under a brand every copy of Core shares. */ +export interface SharedBrandKey { + /** The registered key, for an object literal: `{[brand.key]: value}`. */ + readonly key: symbol; + /** The branded value of `target`, or undefined when it carries none. */ + read: (target: unknown) => V | undefined; +} + +/** + * A brand every copy of Core reads the same way, for values one copy makes and + * another copy recognizes, such as a capability a hook returns. + */ +export function sharedBrand( + name: string, + version: number, +): SharedBrandKey { + const key = sharedKey(name, version); + return { + key, + read: target => + typeof target === 'object' && target !== null && key in target + ? (target as {readonly [key: symbol]: V})[key] + : undefined, + }; +} + +/** The next number of a sequence every copy of Core shares, such as element ids. */ +export function sharedSequence(name: string, version: number): () => number { + const counter = sharedState(name, version, () => ({next: 0})); + return () => { + counter.next += 1; + return counter.next; + }; +} + +/** + * Test-only: forget every shared value whose name starts with `prefix`, as on + * a fresh page. With no prefix it forgets them all. + */ +export function resetSharedStateForTest(prefix = ''): void { + const realm = globalThis as typeof globalThis & {[key: symbol]: unknown}; + const start = `@astryxdesign/core/${prefix}`; + for (const key of Object.getOwnPropertySymbols(realm)) { + if (key.description?.startsWith(start)) { + delete realm[key]; + } + } +} diff --git a/scripts/shared-identity.mjs b/scripts/shared-identity.mjs index 304eaa97a6f54..ed27054b4910d 100644 --- a/scripts/shared-identity.mjs +++ b/scripts/shared-identity.mjs @@ -330,3 +330,76 @@ export function findInSource(file, code) { export function findAll() { return sourceFiles().flatMap(findInFile); } + +/** The helpers that name a shared slot, and the prefix each puts before the name. */ +const SLOT_HELPERS = { + sharedKey: '', + sharedState: '', + sharedMap: '', + sharedWeakMap: '', + sharedWeakSet: '', + sharedStore: '', + sharedBrand: '', + sharedSequence: '', + createSharedContext: 'context/', + createLayerScopedContext: 'context/', +}; +/** Files that define a slot helper, where the name is the caller's parameter. */ +const HELPER_FILES = new Set([ + 'packages/core/src/utils/sharedRealm.ts', + 'packages/core/src/utils/sharedContext.ts', + 'packages/core/src/Layer/layerScopedContext.tsx', +]); + +/** + * Every call that names a shared slot. A slot's name and version are its + * identity, so each call must spell both as literals. + * @returns {Array<{file: string, line: number, helper: string, slot: string|null}>} + * `slot` is `/v`, or null when either is not a literal + */ +export function sharedSlots() { + /** @type {Array<{file: string, line: number, helper: string, slot: string|null}>} */ + const slots = []; + for (const file of sourceFiles()) { + if (HELPER_FILES.has(file)) continue; + const code = fs.readFileSync(path.join(REPO, file), 'utf8'); + if ( + !/\b(?:shared[A-Z]\w*|create(?:Shared|LayerScoped)Context)\b/.test(code) + ) { + continue; + } + const source = ts.createSourceFile( + file, + code, + ts.ScriptTarget.Latest, + true, + ); + const visit = node => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + Object.hasOwn(SLOT_HELPERS, node.expression.text) + ) { + const helper = node.expression.text; + const [name, version] = node.arguments; + const literal = + name && + version && + (ts.isStringLiteral(name) || + ts.isNoSubstitutionTemplateLiteral(name)) && + ts.isNumericLiteral(version); + slots.push({ + file, + line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1, + helper, + slot: literal + ? `${SLOT_HELPERS[helper]}${name.text}/v${version.text}` + : null, + }); + } + ts.forEachChild(node, visit); + }; + visit(source); + } + return slots; +} diff --git a/scripts/shared-identity.test.mjs b/scripts/shared-identity.test.mjs index c6199f0817768..6779b8f410d01 100644 --- a/scripts/shared-identity.test.mjs +++ b/scripts/shared-identity.test.mjs @@ -15,7 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {describe, expect, it} from 'vitest'; -import {REPO, findAll, findInSource} from './shared-identity.mjs'; +import {REPO, findAll, findInSource, sharedSlots} from './shared-identity.mjs'; const BASELINE = path.join(REPO, 'scripts/shared-identity.baseline.json'); const FIX = @@ -105,6 +105,29 @@ describe('cross-module identity in module scope (spec:AST-110 FR3)', () => { ).toEqual([]); }); + it('names every shared slot with a literal name and version, once', () => { + // A slot's name and version are its identity across copies: a computed + // name could change with a file move, and two calls with one name would + // share a slot by accident. A move keeps the name, or bumps the version. + const slots = sharedSlots(); + expect( + slots + .filter(slot => slot.slot == null) + .map(slot => `${slot.file}:${slot.line} ${slot.helper}`), + 'shared slots named without a literal name and version', + ).toEqual([]); + const seen = new Map(); + const repeated = []; + for (const slot of slots) { + if (slot.slot == null) continue; + const first = seen.get(slot.slot); + if (first) + repeated.push(`${slot.slot}: ${first} and ${slot.file}:${slot.line}`); + else seen.set(slot.slot, `${slot.file}:${slot.line}`); + } + expect(repeated, 'shared slots named twice').toEqual([]); + }); + it('gives every allowed entry a reason and every open entry a fix group', () => { const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).entries; for (const [key, entry] of Object.entries(baseline)) { From 460632c15a7e2bc8ea25714ab9413b0a8e72543b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 07:13:07 -0800 Subject: [PATCH 03/12] chore(changeset): note the internal shared-realm helpers --- .changeset/shared-realm-helpers.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/shared-realm-helpers.md diff --git a/.changeset/shared-realm-helpers.md b/.changeset/shared-realm-helpers.md new file mode 100644 index 0000000000000..2b867f9616cd0 --- /dev/null +++ b/.changeset/shared-realm-helpers.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Core's shared-realm module gains helpers for state that the package and a swizzled copy share (state, maps, stores, brands and sequences). No API or behavior changes. + +@cixzhang From 1677454d233080edb8c57a779e21795a453e8b56 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 07:45:40 -0800 Subject: [PATCH 04/12] feat(core): sharedForReact keeps a React-bound shared value one per React --- docs/specs/AST-110-swizzle-output/spec.md | 3 +- .../core/src/Layer/sharedContexts.test.tsx | 20 +++++++++++++ packages/core/src/utils/sharedContext.ts | 28 +++++++++++++++++-- scripts/shared-identity.mjs | 1 + 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-110-swizzle-output/spec.md b/docs/specs/AST-110-swizzle-output/spec.md index 7590c1269f615..4cc624cbe04b6 100644 --- a/docs/specs/AST-110-swizzle-output/spec.md +++ b/docs/specs/AST-110-swizzle-output/spec.md @@ -100,7 +100,8 @@ and the repository proves it before a change that could break it merges. another's provided value. A symbol that identifies objects across modules, such as a brand or a context key, is registered under the same kind of key, so a copy and the package agree on it. Such state is held through Core's shared-realm - helpers, so a copy and the package resolve the same slot; a module-scope + helpers, so a copy and the package resolve the same slot, and state that + holds React components or elements is kept one per React; a module-scope registry, counter or brand outside them fails the source check unless the allowlist names it with a reason. The rule holds in both directions: a copy inside a package overlay, and package components inside a copied Dialog or other diff --git a/packages/core/src/Layer/sharedContexts.test.tsx b/packages/core/src/Layer/sharedContexts.test.tsx index d8cd76027e7ab..002d52cfc83a8 100644 --- a/packages/core/src/Layer/sharedContexts.test.tsx +++ b/packages/core/src/Layer/sharedContexts.test.tsx @@ -154,6 +154,26 @@ describe('two Reacts in one process', () => { expect(other).not.toBe(context); expect(onOtherReact.createSharedContext(name, 1, 'default')).toBe(other); }); + + it('gives each React its own React-bound value, and one per React', async () => { + const otherReact = secondReact(); + const onThisReact = await freshInstance( + async () => import('../utils/sharedContext'), + ); + const copyOnThisReact = await freshInstance( + async () => import('../utils/sharedContext'), + ); + const onOtherReact = await sharedContextOn(otherReact); + const value = onThisReact.sharedForReact('test/two-reacts', 1, () => ({})); + expect( + copyOnThisReact.sharedForReact('test/two-reacts', 1, () => ({})), + ).toBe(value); + const other = onOtherReact.sharedForReact('test/two-reacts', 1, () => ({})); + expect(other).not.toBe(value); + expect(onOtherReact.sharedForReact('test/two-reacts', 1, () => ({}))).toBe( + other, + ); + }); }); describe('every Core context is registered under a versioned key', () => { diff --git a/packages/core/src/utils/sharedContext.ts b/packages/core/src/utils/sharedContext.ts index 05a786cd1a2b3..cc157e510ab59 100644 --- a/packages/core/src/utils/sharedContext.ts +++ b/packages/core/src/utils/sharedContext.ts @@ -5,8 +5,8 @@ /** * @file sharedContext.ts * @input A registered name and its protocol version - * @output A React context per key and React, shared by every copy of Core - * that runs on that React + * @output A React context, or any React-bound value, per key and React, + * shared by every copy of Core that runs on that React * @position Shared runtime state: a swizzled copy and the package use the same * contexts and stores when their protocol versions match */ @@ -40,3 +40,27 @@ export function createSharedContext( byReact.set(createContext, SharedContext as Context); return SharedContext; } + +/** + * One value per React under a registered name, shared by every copy of Core + * on that React. Use it for state that holds React components or elements: a + * copy on another React keeps its own value, so it never renders a component + * written against the other React. + */ +export function sharedForReact( + name: string, + version: number, + create: () => T, +): T { + const byReact = realmValue( + sharedKey(`react/${name}`, version), + () => new WeakMap(), + value => value instanceof WeakMap, + ); + if (byReact.has(createContext)) { + return byReact.get(createContext) as T; + } + const value = create(); + byReact.set(createContext, value); + return value; +} diff --git a/scripts/shared-identity.mjs b/scripts/shared-identity.mjs index ed27054b4910d..e960a786ff2a2 100644 --- a/scripts/shared-identity.mjs +++ b/scripts/shared-identity.mjs @@ -342,6 +342,7 @@ const SLOT_HELPERS = { sharedBrand: '', sharedSequence: '', createSharedContext: 'context/', + sharedForReact: 'react/', createLayerScopedContext: 'context/', }; /** Files that define a slot helper, where the name is the caller's parameter. */ From 9aca48de8186ded43d8bfcbe35d0aae85efe1622 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 08:21:54 -0800 Subject: [PATCH 05/12] fix(core): reset shared state without a dynamic delete --- packages/core/src/utils/sharedRealm.test.ts | 3 ++- packages/core/src/utils/sharedRealm.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/sharedRealm.test.ts b/packages/core/src/utils/sharedRealm.test.ts index 9ca7f7812630d..03f82586a5270 100644 --- a/packages/core/src/utils/sharedRealm.test.ts +++ b/packages/core/src/utils/sharedRealm.test.ts @@ -10,8 +10,9 @@ */ import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as RealmModule from './sharedRealm'; -type Realm = typeof import('./sharedRealm'); +type Realm = typeof RealmModule; /** Two loads of the module: the package's and a copy's. */ async function twoCopies(): Promise<[Realm, Realm]> { diff --git a/packages/core/src/utils/sharedRealm.ts b/packages/core/src/utils/sharedRealm.ts index 2a054307153f7..dabe1ae47f1b0 100644 --- a/packages/core/src/utils/sharedRealm.ts +++ b/packages/core/src/utils/sharedRealm.ts @@ -170,7 +170,7 @@ export function resetSharedStateForTest(prefix = ''): void { const start = `@astryxdesign/core/${prefix}`; for (const key of Object.getOwnPropertySymbols(realm)) { if (key.description?.startsWith(start)) { - delete realm[key]; + Reflect.deleteProperty(realm, key); } } } From 53b6023d027f527da8dd1adc3fe1780ba20875bf Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 08:43:24 -0800 Subject: [PATCH 06/12] fix(core): a shared brand's type member is required --- packages/core/src/utils/sharedRealm.test.ts | 5 +++++ packages/core/src/utils/sharedRealm.ts | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/sharedRealm.test.ts b/packages/core/src/utils/sharedRealm.test.ts index 03f82586a5270..e955ed864d41e 100644 --- a/packages/core/src/utils/sharedRealm.test.ts +++ b/packages/core/src/utils/sharedRealm.test.ts @@ -11,6 +11,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import type * as RealmModule from './sharedRealm'; +import type {SharedBrand} from './sharedRealm'; type Realm = typeof RealmModule; @@ -67,6 +68,10 @@ describe('shared realm shapes (spec:AST-110 FR3)', () => { const branded = {[copyBrand.key]: {size: 4}}; expect(pkgBrand.read(branded)).toEqual({size: 4}); expect(pkgBrand.read({size: 4})).toBeUndefined(); + // The type member is required, so a plain object is not branded. + // @ts-expect-error -- `{}` lacks the brand. + const unbranded: SharedBrand<'test/brand/v1'> = {}; + expect(pkgBrand.read(unbranded)).toBeUndefined(); expect(pkgBrand.read(null)).toBeUndefined(); }); diff --git a/packages/core/src/utils/sharedRealm.ts b/packages/core/src/utils/sharedRealm.ts index dabe1ae47f1b0..742046774dbef 100644 --- a/packages/core/src/utils/sharedRealm.ts +++ b/packages/core/src/utils/sharedRealm.ts @@ -119,11 +119,12 @@ export function sharedStore( } /** - * The type side of a shared brand: one optional member that is never present, - * so the branded types of the package and a swizzled copy match. + * The type side of a shared brand: one member that is never present at + * runtime. It is required, so only code that casts makes a branded value, and + * its name is the same in the package and a swizzled copy. */ export type SharedBrand = { - readonly [K in `@astryxdesign/core/${Name}`]?: never; + readonly [K in `@astryxdesign/core/${Name}`]: never; }; /** Marks and reads a value under a brand every copy of Core shares. */ From 391511450ae527ed0473fe1fdbf421878081c475 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 21:03:22 -0800 Subject: [PATCH 07/12] spec(AST-110): FR3 states the observable slot guarantee; the mechanism moves to verification --- docs/specs/AST-110-swizzle-output/spec.md | 33 +++++++++++------------ 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/specs/AST-110-swizzle-output/spec.md b/docs/specs/AST-110-swizzle-output/spec.md index 4cc624cbe04b6..dbf427faacc48 100644 --- a/docs/specs/AST-110-swizzle-output/spec.md +++ b/docs/specs/AST-110-swizzle-output/spec.md @@ -99,11 +99,9 @@ and the repository proves it before a change that could break it merges. is never shared across two Reacts in one process, so one request never reads another's provided value. A symbol that identifies objects across modules, such as a brand or a context key, is registered under the same kind of key, so a copy - and the package agree on it. Such state is held through Core's shared-realm - helpers, so a copy and the package resolve the same slot, and state that - holds React components or elements is kept one per React; a module-scope - registry, counter or brand outside them fails the source check unless the - allowlist names it with a reason. The rule holds in both directions: a copy inside + and the package agree on it. Each such store, brand or registry has one + versioned slot per page, and one that holds React components or elements has + one per React. The rule holds in both directions: a copy inside a package overlay, and package components inside a copied Dialog or other overlay. A package component inside an open copied modal delivers its announcements inside that modal (`spec:AST-086` FR14), and package group and @@ -224,18 +222,19 @@ and the repository proves it before a change that could break it merges. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1 | Eject every offered component into a clean project; type-check and bundle | Every component in `swizzle --list`; Core, Lab, Charts, RichText, Vega; a component that imports an npm dependency | A type or resolve error, or an import of a path the owner's `exports` lacks or of `./internal/*`, other than a StyleX module another Astryx package exports there (FR1), fails, or a dependency the copy imports goes unreported | -| FR2 | Import graph of each output | Helpers in `utils`, `hooks`, `Layer`; nested folders; colocated tests and docs; a helper an earlier swizzle wrote, identical and different | A copied file imports a module that is neither in the output nor a public export, or a test, story, spec, or doc is copied, or an existing helper other than an owner module (FR4) is overwritten | -| FR3 | Render a copied and a package layer component together; inspect stores; open, stack, and Escape | Dialog over package Popover; BottomSheet over package Dialog; modality; a copied `useToast` under the package `LayerProvider`; a symbol-branded object passed between copy and package; a swizzled Outline type-checked against the package's `MarkdownPluginEntry`; a copied Dialog announcing beside a package component; a package picker inside a copied Dialog announcing a result count; a package grouped control inside a copied overlay | In either direction (copy under package, package under copy): two stores of one protocol version exist in a document, Escape and focus return differ from the package alone, a copied consumer misses the package provider, a brand check fails, a second live region appears instead of one announcer (`spec:AST-086` FR9), an announcement escapes the open modal (`spec:AST-086` FR14), or group context crosses the layer boundary (`spec:AST-038` FR7) | -| FR3 | `sharedContexts.test.tsx` "gives each React its own context under a key, and one per React": a second React instance loads a copy of the shared-context module | Two copies on one React; a copy on a second React in the same process | Two copies on one React getting different contexts, or a copy on a second React getting the same context object, fails | -| FR4 | Scan each output for private data-attribute names and selectors; compare copied owner modules with the package's | Components that reach an owner module | A hand-written private attribute name or selector, or an edited owner module, in the output | -| FR5 | Render package and copy side by side under a theme with component overrides; compare | Default; each variant; a theme component override; a copy built by `@astryxdesign/build` | A theme override, documented class name, data attribute, or theme target reaches one and not the other | -| FR5 | Swizzle into a new folder, an empty one, a marked one, and one holding a product module; read the folder and the `swizzle.copy` result | Default output; `--output` to a product folder; a folder of earlier copies with no marker | A marker lands in a folder that holds a product module, a new, empty or marked folder is left unmarked, or the result omits what a skipped folder holds | -| FR6 | The component's own interaction and accessibility checks run against the copy | Keyboard, pointer, screen-reader tree | A check that passes on the package fails on the copy | -| FR7 | The required test lane's swizzle proof and the release-time proof; both cover Core's list and every integration package's components through `--package` | A change to component source; a change to the swizzle copy; an unrelated change; Core, Lab, Charts, RichText, Vega | An offered component missing from the proof, a failure that does not block, a new required context, the proof adding wall-clock time to the slowest required check, or the proof pushing its job past the pull-request time budget | -| FR7 | `scripts/shared-identity.test.mjs` over the source of the packages swizzle copies from, against its baseline | A module-local `Symbol()` brand in an FR3 module; the same brand under `sharedKey`; a module-scope registry an exported register function writes | The module-local brand or registry passes, the `sharedKey` brand fails, or a baseline entry the source no longer has stays listed | +| Contract | Verification | Representative states | Mutation or failure expectation | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | Eject every offered component into a clean project; type-check and bundle | Every component in `swizzle --list`; Core, Lab, Charts, RichText, Vega; a component that imports an npm dependency | A type or resolve error, or an import of a path the owner's `exports` lacks or of `./internal/*`, other than a StyleX module another Astryx package exports there (FR1), fails, or a dependency the copy imports goes unreported | +| FR2 | Import graph of each output | Helpers in `utils`, `hooks`, `Layer`; nested folders; colocated tests and docs; a helper an earlier swizzle wrote, identical and different | A copied file imports a module that is neither in the output nor a public export, or a test, story, spec, or doc is copied, or an existing helper other than an owner module (FR4) is overwritten | +| FR3 | Render a copied and a package layer component together; inspect stores; open, stack, and Escape | Dialog over package Popover; BottomSheet over package Dialog; modality; a copied `useToast` under the package `LayerProvider`; a symbol-branded object passed between copy and package; a swizzled Outline type-checked against the package's `MarkdownPluginEntry`; a copied Dialog announcing beside a package component; a package picker inside a copied Dialog announcing a result count; a package grouped control inside a copied overlay | In either direction (copy under package, package under copy): two stores of one protocol version exist in a document, Escape and focus return differ from the package alone, a copied consumer misses the package provider, a brand check fails, a second live region appears instead of one announcer (`spec:AST-086` FR9), an announcement escapes the open modal (`spec:AST-086` FR14), or group context crosses the layer boundary (`spec:AST-038` FR7) | +| FR3 | `sharedContexts.test.tsx` "gives each React its own context under a key, and one per React": a second React instance loads a copy of the shared-context module | Two copies on one React; a copy on a second React in the same process | Two copies on one React getting different contexts, or a copy on a second React getting the same context object, fails | +| FR3 | `sharedRealm.test.ts` and `sharedContexts.test.tsx`: two loads of each shared-state shape resolve one slot, and a React-bound slot is one per React; FR7's source check fails on module-scope state outside a registered shared key unless its baseline lists it with a reason or a fix group. Slots use literal stable names; React-bound slots use the per-React form; brands carry the required phantom member | Two loads on one React; a load on a second React | A second load gets its own slot, or a React-bound slot crosses Reacts | +| FR4 | Scan each output for private data-attribute names and selectors; compare copied owner modules with the package's | Components that reach an owner module | A hand-written private attribute name or selector, or an edited owner module, in the output | +| FR5 | Render package and copy side by side under a theme with component overrides; compare | Default; each variant; a theme component override; a copy built by `@astryxdesign/build` | A theme override, documented class name, data attribute, or theme target reaches one and not the other | +| FR5 | Swizzle into a new folder, an empty one, a marked one, and one holding a product module; read the folder and the `swizzle.copy` result | Default output; `--output` to a product folder; a folder of earlier copies with no marker | A marker lands in a folder that holds a product module, a new, empty or marked folder is left unmarked, or the result omits what a skipped folder holds | +| FR6 | The component's own interaction and accessibility checks run against the copy | Keyboard, pointer, screen-reader tree | A check that passes on the package fails on the copy | +| FR7 | The required test lane's swizzle proof and the release-time proof; both cover Core's list and every integration package's components through `--package` | A change to component source; a change to the swizzle copy; an unrelated change; Core, Lab, Charts, RichText, Vega | An offered component missing from the proof, a failure that does not block, a new required context, the proof adding wall-clock time to the slowest required check, or the proof pushing its job past the pull-request time budget | +| FR7 | `scripts/shared-identity.test.mjs` over the source of the packages swizzle copies from, against its baseline | A module-local `Symbol()` brand in an FR3 module; the same brand under `sharedKey`; a module-scope registry an exported register function writes | The module-local brand or registry passes, the `sharedKey` brand fails, or a baseline entry the source no longer has stays listed | Known verification gap: only FR3's shared-context checks (`sharedContexts.test.tsx`) and FR7's source check (`scripts/shared-identity.test.mjs`) exist; the other rows have no check on `main`. From be4359425a6b42d173ce7d3c095447d76782a075 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 07:56:19 -0800 Subject: [PATCH 08/12] test(core): a scroll lock from a swizzled copy keeps the package's page lock (red) --- .../src/hooks/useScrollLock.shared.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/core/src/hooks/useScrollLock.shared.test.ts diff --git a/packages/core/src/hooks/useScrollLock.shared.test.ts b/packages/core/src/hooks/useScrollLock.shared.test.ts new file mode 100644 index 0000000000000..668666cb92ac1 --- /dev/null +++ b/packages/core/src/hooks/useScrollLock.shared.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useScrollLock.shared.test.ts + * @input Two loads of useScrollLock and its modules, as the package and a + * swizzled copy each carry one + * @output Proves the page stays locked while a lock from either load holds, + * and comes back to its own style after the last release + * @position Shared-state tests for useScrollLock.ts and rootScrollStyles.ts + * (spec:AST-110 FR3) + */ + +import {cleanup, renderHook} from '@testing-library/react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +type ScrollLockModule = typeof import('./useScrollLock'); +type RootStylesModule = typeof import('./rootScrollStyles'); + +/** One load of the scroll-lock modules, as the package or a copy has. */ +async function load(): Promise<{ + useScrollLock: ScrollLockModule['useScrollLock']; + isScrollLockActive: RootStylesModule['isScrollLockActive']; +}> { + vi.resetModules(); + const {useScrollLock} = await import('./useScrollLock'); + const {isScrollLockActive} = await import('./rootScrollStyles'); + return {useScrollLock, isScrollLockActive}; +} + +afterEach(async () => { + cleanup(); + (await import('../utils/sharedRealm')).resetSharedStateForTest('hooks/'); + vi.resetModules(); + document.documentElement.style.overflow = ''; +}); + +describe('a scroll lock shared by the package and a swizzled copy', () => { + it('keeps the page locked while the other load still holds a lock', async () => { + const pkg = await load(); + const copy = await load(); + const pkgLock = renderHook(() => pkg.useScrollLock(true)); + const copyLock = renderHook(() => copy.useScrollLock(true)); + expect(document.documentElement.style.overflow).toBe('hidden'); + + pkgLock.unmount(); + // The copy's overlay is still open, so the page must stay locked. + expect(document.documentElement.style.overflow).toBe('hidden'); + expect(pkg.isScrollLockActive()).toBe(true); + + copyLock.unmount(); + expect(document.documentElement.style.overflow).toBe(''); + expect(copy.isScrollLockActive()).toBe(false); + }); + + it("restores the page's own overflow after the last release, in either order", async () => { + document.documentElement.style.overflow = 'auto'; + const pkg = await load(); + const copy = await load(); + const copyLock = renderHook(() => copy.useScrollLock(true)); + const pkgLock = renderHook(() => pkg.useScrollLock(true)); + copyLock.unmount(); + expect(document.documentElement.style.overflow).toBe('hidden'); + pkgLock.unmount(); + expect(document.documentElement.style.overflow).toBe('auto'); + }); +}); From 4446e5e0d822894aeb3ace4c4ce616783b3b6021 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 08:06:53 -0800 Subject: [PATCH 09/12] fix(core): the page scroll lock is one per page across swizzled copies --- .changeset/scroll-lock-swizzle-shared.md | 7 +++ packages/core/src/gestures/pageHold.ts | 7 ++- packages/core/src/hooks/rootScrollStyles.ts | 59 +++++++++++---------- packages/core/src/hooks/useScrollLock.ts | 29 +++++----- packages/core/src/utils/selectionLock.ts | 11 ++-- scripts/shared-identity.baseline.json | 36 ------------- 6 files changed, 69 insertions(+), 80 deletions(-) create mode 100644 .changeset/scroll-lock-swizzle-shared.md diff --git a/.changeset/scroll-lock-swizzle-shared.md b/.changeset/scroll-lock-swizzle-shared.md new file mode 100644 index 0000000000000..5f86c58507ed9 --- /dev/null +++ b/.changeset/scroll-lock-swizzle-shared.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A swizzled overlay and the package's overlays now share one page scroll lock: closing one keeps the page locked while the other is still open, and the page's own overflow comes back after the last one closes. Before, each copy kept its own count and saved styles, so closing a package Dialog over an open swizzled Drawer (or the reverse) let the page scroll behind the open overlay. Text-selection locks and page touch holds are shared the same way. No API changes. + +@cixzhang diff --git a/packages/core/src/gestures/pageHold.ts b/packages/core/src/gestures/pageHold.ts index c477b18f4eb88..c67918a0b1cff 100644 --- a/packages/core/src/gestures/pageHold.ts +++ b/packages/core/src/gestures/pageHold.ts @@ -29,6 +29,7 @@ import {subscribeInput} from '../utils/inputStream'; import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer'; import {gestureHosts} from './hostRegistry'; +import {sharedWeakMap} from '../utils/sharedRealm'; /** * Decides, once per one-finger touch, whether its pan would scroll the page: @@ -71,7 +72,11 @@ interface PageHolds { stopWatchingPointer: (() => void) | null; } -const holdsByDocument = new WeakMap(); +// Every copy of Core holds the page through one entry per document. +const holdsByDocument = sharedWeakMap( + 'gestures/page-hold/holds-by-document', + 1, +); function fingersOf(event: TouchEvent): Touch[] { return event.touches == null ? [] : Array.from(event.touches); diff --git a/packages/core/src/hooks/rootScrollStyles.ts b/packages/core/src/hooks/rootScrollStyles.ts index 282768d45dd0f..273c94833b946 100644 --- a/packages/core/src/hooks/rootScrollStyles.ts +++ b/packages/core/src/hooks/rootScrollStyles.ts @@ -16,6 +16,8 @@ * - /packages/core/src/hooks/useScrollLock.ts */ +import {sharedState} from '../utils/sharedRealm'; + /** The root scroll styles a caller can hold. */ export interface RootScrollStyle { /** `hidden` or `clip` stops wheel, keyboard and scrollbar scrolling of the page. */ @@ -39,12 +41,6 @@ interface Claim { readonly value: string; } -const claims: Record = { - overflow: [], - overscrollBehaviorY: [], - scrollbarGutter: [], -}; - type SavedStyle = | 'overflow' | 'overflowX' @@ -72,9 +68,19 @@ interface HeldTarget { written: string; } -const held = new Map(); - -let nextClaimId = 0; +// Every copy of Core uses this one state, so a swizzled overlay and the +// package's stack their claims and locks on the same page. +const state = sharedState('hooks/root-scroll-styles/state', 1, () => ({ + claims: { + overflow: [], + overscrollBehaviorY: [], + scrollbarGutter: [], + }, + held: new Map(), + nextClaimId: 0, + lockCount: 0, + lockListeners: new Set<(isActive: boolean) => void>(), +})); /** * The element whose `overflow` the viewport uses. That is the root, unless the @@ -121,11 +127,11 @@ function claimTargets( } function applyTopClaim(property: RootScrollProperty): void { - const targets = held.get(property); + const targets = state.held.get(property); if (targets == null) { return; } - const stack = claims[property]; + const stack = state.claims[property]; const top = stack[stack.length - 1]; for (const target of targets) { const {style} = target.element; @@ -147,7 +153,7 @@ function applyTopClaim(property: RootScrollProperty): void { } } if (top == null) { - held.delete(property); + state.held.delete(property); } } @@ -169,16 +175,16 @@ export function claimRootScrollStyle(claim: RootScrollStyle): () => void { if (typeof document === 'undefined') { return () => {}; } - nextClaimId += 1; - const id = nextClaimId; + state.nextClaimId += 1; + const id = state.nextClaimId; const claimed: RootScrollProperty[] = []; for (const property of PROPERTIES) { const value = claim[property]; if (value == null) { continue; } - if (claims[property].length === 0) { - held.set( + if (state.claims[property].length === 0) { + state.held.set( property, claimTargets(property, document).map(element => { const pageValues: Partial> = {}; @@ -189,7 +195,7 @@ export function claimRootScrollStyle(claim: RootScrollStyle): () => void { }), ); } - claims[property].push({id, value}); + state.claims[property].push({id, value}); claimed.push(property); applyTopClaim(property); } @@ -201,7 +207,7 @@ export function claimRootScrollStyle(claim: RootScrollStyle): () => void { } released = true; for (const property of claimed) { - const stack = claims[property]; + const stack = state.claims[property]; const index = stack.findIndex(entry => entry.id === id); if (index !== -1) { stack.splice(index, 1); @@ -211,12 +217,9 @@ export function claimRootScrollStyle(claim: RootScrollStyle): () => void { }; } -let lockCount = 0; -const lockListeners = new Set<(isActive: boolean) => void>(); - /** Whether a scroll lock (useScrollLock) currently holds the page. */ export function isScrollLockActive(): boolean { - return lockCount > 0; + return state.lockCount > 0; } /** @@ -227,21 +230,21 @@ export function isScrollLockActive(): boolean { export function subscribeScrollLock( listener: (isActive: boolean) => void, ): () => void { - lockListeners.add(listener); + state.lockListeners.add(listener); return () => { - lockListeners.delete(listener); + state.lockListeners.delete(listener); }; } /** For scroll locks: one lock taken (`1`) or released (`-1`). */ export function reportScrollLock(delta: 1 | -1): void { - const wasActive = lockCount > 0; - lockCount = Math.max(0, lockCount + delta); - const isActive = lockCount > 0; + const wasActive = state.lockCount > 0; + state.lockCount = Math.max(0, state.lockCount + delta); + const isActive = state.lockCount > 0; if (isActive === wasActive) { return; } - for (const listener of [...lockListeners]) { + for (const listener of [...state.lockListeners]) { listener(isActive); } } diff --git a/packages/core/src/hooks/useScrollLock.ts b/packages/core/src/hooks/useScrollLock.ts index aef20bcd4f6ef..dc7534a207e9d 100644 --- a/packages/core/src/hooks/useScrollLock.ts +++ b/packages/core/src/hooks/useScrollLock.ts @@ -23,6 +23,7 @@ import {claimRootScrollStyle, reportScrollLock} from './rootScrollStyles'; import {holdPageScroll} from '../gestures/pageHold'; import {isPageZoomedIn, isPanAbsorbedBeforePage} from './scrollLockTouchGuard'; import {holdScrollbarGutter, type ScrollbarGutterHold} from './scrollbarGutter'; +import {sharedState} from '../utils/sharedRealm'; interface ActiveLock { releaseRootStyle: () => void; @@ -30,8 +31,12 @@ interface ActiveLock { gutter: ScrollbarGutterHold; } -let lockCount = 0; -let activeLock: ActiveLock | null = null; +// Every copy of Core counts holders here, so the page stays locked until the +// last holder in any copy releases. +const lock = sharedState('hooks/use-scroll-lock/lock', 1, () => ({ + count: 0, + active: null as ActiveLock | null, +})); /** * Locks page scroll while `isLocked` is true. @@ -57,7 +62,7 @@ export function useScrollLock(isLocked: boolean): void { return; } - if (lockCount === 0) { + if (lock.count === 0) { // The hold measures before the lock writes a style, so the lock forces no // layout of its own. `settle` writes the gutter with the styles below. const gutter = holdScrollbarGutter(document.documentElement); @@ -78,25 +83,25 @@ export function useScrollLock(isLocked: boolean): void { !isPageZoomedIn(document) && !isPanAbsorbedBeforePage(origin, dx, dy), ); gutter.settle(); - activeLock = {releaseRootStyle, releasePageHold, gutter}; + lock.active = {releaseRootStyle, releasePageHold, gutter}; } - lockCount += 1; + lock.count += 1; reportScrollLock(1); return () => { - lockCount -= 1; + lock.count -= 1; reportScrollLock(-1); - if (lockCount !== 0 || activeLock == null) { + if (lock.count !== 0 || lock.active == null) { return; } - const lock = activeLock; - activeLock = null; - lock.releasePageHold(); - lock.releaseRootStyle(); - lock.gutter.release(); + const active = lock.active; + lock.active = null; + active.releasePageHold(); + active.releaseRootStyle(); + active.gutter.release(); }; }, [isLocked]); } diff --git a/packages/core/src/utils/selectionLock.ts b/packages/core/src/utils/selectionLock.ts index bcb14f5e529f0..daab3ac2cc50f 100644 --- a/packages/core/src/utils/selectionLock.ts +++ b/packages/core/src/utils/selectionLock.ts @@ -38,6 +38,7 @@ import { import {subscribeInput} from './inputStream'; import {isIosWebKit} from './touchQueries'; import {isElement, isNode} from './nodeType'; +import {sharedWeakMap} from './sharedRealm'; /** What a held finger must not start on a control or a cell. */ const SELECTION_LOCK = [ @@ -66,14 +67,18 @@ export function locksSelectionFor(pointerType: string): boolean { return pointerType !== 'mouse'; } -/** Locked elements, how many contacts hold each, and what to restore. */ -const locks = new WeakMap< +/** + * Locked elements, how many contacts hold each, and what to restore. Every + * copy of Core counts here, so the first lock's saved styles come back only + * after the last release in any copy. + */ +const locks = sharedWeakMap< Element, { count: number; saved: ReadonlyArray<{name: string; value: string; priority: string}>; } ->(); +>('utils/selection-lock/locks', 1); /** * Refuse a selection and the touch callout on `elements` (and on a non-text diff --git a/scripts/shared-identity.baseline.json b/scripts/shared-identity.baseline.json index b72d32cb3e2b9..de84c50c67380 100644 --- a/scripts/shared-identity.baseline.json +++ b/scripts/shared-identity.baseline.json @@ -409,10 +409,6 @@ "status": "open", "group": "F13 Shared id sequences" }, - "packages/core/src/gestures/pageHold.ts:holdsByDocument": { - "status": "open", - "group": "F3 Scroll lock" - }, "packages/core/src/gestures/touchActionRule.ts:markedRoots": { "status": "allowed", "reason": "Deduplicates an idempotent style insertion; a copy can at most insert the same rules again." @@ -433,26 +429,6 @@ "status": "open", "group": "F5 Focus and announce" }, - "packages/core/src/hooks/rootScrollStyles.ts:claims": { - "status": "open", - "group": "F3 Scroll lock" - }, - "packages/core/src/hooks/rootScrollStyles.ts:held": { - "status": "open", - "group": "F3 Scroll lock" - }, - "packages/core/src/hooks/rootScrollStyles.ts:lockCount": { - "status": "open", - "group": "F3 Scroll lock" - }, - "packages/core/src/hooks/rootScrollStyles.ts:lockListeners": { - "status": "open", - "group": "F3 Scroll lock" - }, - "packages/core/src/hooks/rootScrollStyles.ts:nextClaimId": { - "status": "open", - "group": "F3 Scroll lock" - }, "packages/core/src/hooks/scrollGeometrySubscriptions.ts:listeners": { "status": "allowed", "reason": "Each copy observes for its own subscribers; a second observer changes no result." @@ -513,14 +489,6 @@ "status": "open", "group": "triage" }, - "packages/core/src/hooks/useScrollLock.ts:activeLock": { - "status": "open", - "group": "F3 Scroll lock" - }, - "packages/core/src/hooks/useScrollLock.ts:lockCount": { - "status": "open", - "group": "F3 Scroll lock" - }, "packages/core/src/i18n/resolve.ts:formatterCache": { "status": "allowed", "reason": "A memo cache; a second cache per copy changes no result." @@ -917,10 +885,6 @@ "status": "open", "group": "triage" }, - "packages/core/src/utils/selectionLock.ts:locks": { - "status": "open", - "group": "F3 Scroll lock" - }, "packages/core/src/utils/sharedMutationObserver.ts:observers": { "status": "allowed", "reason": "Each copy observes for its own subscribers; a second observer changes no result." From 2d7d70c16456e3f2ee654e6acba3cc2c940d7ecd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 08:19:40 -0800 Subject: [PATCH 10/12] fix(core): type the shared scroll-claim stacks --- packages/core/src/hooks/rootScrollStyles.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/src/hooks/rootScrollStyles.ts b/packages/core/src/hooks/rootScrollStyles.ts index 273c94833b946..8a9e920d03bfa 100644 --- a/packages/core/src/hooks/rootScrollStyles.ts +++ b/packages/core/src/hooks/rootScrollStyles.ts @@ -70,17 +70,20 @@ interface HeldTarget { // Every copy of Core uses this one state, so a swizzled overlay and the // package's stack their claims and locks on the same page. -const state = sharedState('hooks/root-scroll-styles/state', 1, () => ({ - claims: { +const state = sharedState('hooks/root-scroll-styles/state', 1, () => { + const claims: Record = { overflow: [], overscrollBehaviorY: [], scrollbarGutter: [], - }, - held: new Map(), - nextClaimId: 0, - lockCount: 0, - lockListeners: new Set<(isActive: boolean) => void>(), -})); + }; + return { + claims, + held: new Map(), + nextClaimId: 0, + lockCount: 0, + lockListeners: new Set<(isActive: boolean) => void>(), + }; +}); /** * The element whose `overflow` the viewport uses. That is the root, unless the From 3b00d66bd92b8cf10b88abbad82ff65879d48f5f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 08:23:00 -0800 Subject: [PATCH 11/12] test(core): import the scroll-lock module types --- packages/core/src/hooks/useScrollLock.shared.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/hooks/useScrollLock.shared.test.ts b/packages/core/src/hooks/useScrollLock.shared.test.ts index 668666cb92ac1..29c19202e5429 100644 --- a/packages/core/src/hooks/useScrollLock.shared.test.ts +++ b/packages/core/src/hooks/useScrollLock.shared.test.ts @@ -12,14 +12,13 @@ import {cleanup, renderHook} from '@testing-library/react'; import {afterEach, describe, expect, it, vi} from 'vitest'; - -type ScrollLockModule = typeof import('./useScrollLock'); -type RootStylesModule = typeof import('./rootScrollStyles'); +import type * as RootStylesModule from './rootScrollStyles'; +import type * as ScrollLockModule from './useScrollLock'; /** One load of the scroll-lock modules, as the package or a copy has. */ async function load(): Promise<{ - useScrollLock: ScrollLockModule['useScrollLock']; - isScrollLockActive: RootStylesModule['isScrollLockActive']; + useScrollLock: typeof ScrollLockModule.useScrollLock; + isScrollLockActive: typeof RootStylesModule.isScrollLockActive; }> { vi.resetModules(); const {useScrollLock} = await import('./useScrollLock'); From 4cbecd6a87c31deb499cb8e0324e7d7185a2a029 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 21:03:32 -0800 Subject: [PATCH 12/12] chore(changeset): describe the shared scroll and selection locks accurately --- .changeset/scroll-lock-swizzle-shared.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/scroll-lock-swizzle-shared.md b/.changeset/scroll-lock-swizzle-shared.md index 5f86c58507ed9..1308a61148bbe 100644 --- a/.changeset/scroll-lock-swizzle-shared.md +++ b/.changeset/scroll-lock-swizzle-shared.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A swizzled overlay and the package's overlays now share one page scroll lock: closing one keeps the page locked while the other is still open, and the page's own overflow comes back after the last one closes. Before, each copy kept its own count and saved styles, so closing a package Dialog over an open swizzled Drawer (or the reverse) let the page scroll behind the open overlay. Text-selection locks and page touch holds are shared the same way. No API changes. +[fix] Two loads of Core's scroll-lock modules on one page now share one page scroll lock: closing the overlay that opened first no longer unlocks the page while the other overlay is still open. Text-selection locks, which a swizzled copy carries, are shared the same way, so a copy's release no longer restores a selection lock the package still holds. No API changes. @cixzhang