diff --git a/.changeset/scroll-lock-swizzle-shared.md b/.changeset/scroll-lock-swizzle-shared.md new file mode 100644 index 0000000000000..1308a61148bbe --- /dev/null +++ b/.changeset/scroll-lock-swizzle-shared.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[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 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 diff --git a/docs/specs/AST-110-swizzle-output/spec.md b/docs/specs/AST-110-swizzle-output/spec.md index b2397c3af9c57..dbf427faacc48 100644 --- a/docs/specs/AST-110-swizzle-output/spec.md +++ b/docs/specs/AST-110-swizzle-output/spec.md @@ -99,7 +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. 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 @@ -220,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`. 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/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..8a9e920d03bfa 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,22 @@ 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, () => { + const claims: Record = { + overflow: [], + overscrollBehaviorY: [], + scrollbarGutter: [], + }; + 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 @@ -121,11 +130,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 +156,7 @@ function applyTopClaim(property: RootScrollProperty): void { } } if (top == null) { - held.delete(property); + state.held.delete(property); } } @@ -169,16 +178,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 +198,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 +210,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 +220,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 +233,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.shared.test.ts b/packages/core/src/hooks/useScrollLock.shared.test.ts new file mode 100644 index 0000000000000..29c19202e5429 --- /dev/null +++ b/packages/core/src/hooks/useScrollLock.shared.test.ts @@ -0,0 +1,65 @@ +// 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'; +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: typeof ScrollLockModule.useScrollLock; + isScrollLockActive: typeof 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'); + }); +}); 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/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/packages/core/src/utils/sharedRealm.test.ts b/packages/core/src/utils/sharedRealm.test.ts new file mode 100644 index 0000000000000..e955ed864d41e --- /dev/null +++ b/packages/core/src/utils/sharedRealm.test.ts @@ -0,0 +1,93 @@ +// 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'; +import type * as RealmModule from './sharedRealm'; +import type {SharedBrand} from './sharedRealm'; + +type Realm = typeof RealmModule; + +/** 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(); + // 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(); + }); + + 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, + ); + }); +}); diff --git a/packages/core/src/utils/sharedRealm.ts b/packages/core/src/utils/sharedRealm.ts index 48fec3ea52a56..742046774dbef 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,137 @@ 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 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; +}; + +/** 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)) { + Reflect.deleteProperty(realm, key); + } + } +} 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." diff --git a/scripts/shared-identity.mjs b/scripts/shared-identity.mjs index 304eaa97a6f54..e960a786ff2a2 100644 --- a/scripts/shared-identity.mjs +++ b/scripts/shared-identity.mjs @@ -330,3 +330,77 @@ 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/', + sharedForReact: 'react/', + 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)) {