From 574e0dc0cc936b75b816fbd9bc17c951e535721f Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Wed, 30 Sep 2026 19:57:46 +0200 Subject: [PATCH] fix: harden anchored layer for stable 1.0.0 --- .github/workflows/release.yml | 42 ++-- .gitignore | 1 + CHANGELOG.md | 18 +- CONTRIBUTING.md | 37 ++- README.md | 20 +- SECURITY.md | 4 +- docs/API.md | 86 +++++++ package-lock.json | 10 +- package.json | 6 +- scripts/pack-artifact.mjs | 246 +++++++++++++++++- scripts/pack-artifact.test.mjs | 175 +++++++++++++ scripts/verify-release.mjs | 148 +++++++++-- scripts/verify-release.test.mjs | 188 +++++++++++--- scripts/verify-workflows.mjs | 156 +++++++++--- scripts/verify-workflows.test.mjs | 167 ++++++++++--- src/anchor.tsx | 42 ++-- src/compose-refs.ts | 21 +- src/content.tsx | 72 +++--- src/geometry.ts | 3 +- test/browser/geometry.spec.ts | 68 +++++ test/browser/main.tsx | 65 ++++- test/browser/styles.css | 3 + test/package/assert-consumer.cjs.fixture | 53 ++++ test/package/consumer-types.cts.fixture | 21 ++ test/package/consumer-types.tsx.fixture | 86 +++++++ test/package/consumer.cjs.fixture | 6 + test/package/consumer.mjs.fixture | 11 + test/package/verify-package.test.mjs | 302 +++++++++++++++++------ test/unit/anchor.test.tsx | 63 ++++- test/unit/compose-refs.test.ts | 34 +++ test/unit/content-lifecycle.test.tsx | 196 +++++++++++++++ test/unit/content.test.tsx | 8 +- test/unit/geometry.test.ts | 10 + website/src/App.tsx | 33 ++- 34 files changed, 2105 insertions(+), 296 deletions(-) create mode 100644 docs/API.md create mode 100644 scripts/pack-artifact.test.mjs create mode 100644 test/package/assert-consumer.cjs.fixture create mode 100644 test/package/consumer-types.cts.fixture create mode 100644 test/package/consumer-types.tsx.fixture create mode 100644 test/package/consumer.cjs.fixture create mode 100644 test/package/consumer.mjs.fixture create mode 100644 test/unit/compose-refs.test.ts create mode 100644 test/unit/content-lifecycle.test.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26afaa4..5a3cec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,43 +1,53 @@ -name: Release package +name: Stage stable package on: - release: - types: [published] workflow_dispatch: - inputs: - tag: - description: Exact release tag, such as v0.1.0-alpha.0 - required: true - type: string env: - RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} + NPM_CONFIG_USERCONFIG: /dev/null concurrency: - group: npm-publish-${{ github.event.release.tag_name || inputs.tag }} + group: npm-stage-react-anchored-layer-stable cancel-in-progress: false permissions: contents: read - id-token: write jobs: - release: + stage: + if: github.ref == 'refs/heads/main' environment: npm runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v7 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ github.sha }} + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24 registry-url: https://registry.npmjs.org package-manager-cache: false + - run: npm install --global npm@11.19.0 - run: npm ci - run: npm run check - run: npx playwright install --with-deps chromium firefox webkit - run: npm run test:e2e - run: npm run test:website:e2e - - run: node scripts/verify-release.mjs --tag="${RELEASE_TAG}" --ensure-unpublished - - if: ${{ github.event_name == 'release' }} - run: npm publish --provenance --access public --tag alpha + - run: node scripts/verify-release.mjs --ensure-unpublished + - run: node scripts/verify-release.mjs --verify-current-main + - run: node scripts/pack-artifact.mjs + - run: ANCHORED_LAYER_PACKAGE_TARBALL=./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz npm run test:package + - uses: actions/upload-artifact@v7 + with: + name: npm-react-anchored-layer-1.0.0-${{ github.sha }} + path: artifacts/ + if-no-files-found: error + retention-days: 7 + - run: node scripts/pack-artifact.mjs --verify-retained + - run: node scripts/verify-release.mjs --ensure-unpublished + - run: node scripts/verify-release.mjs --verify-current-main + - run: npm stage publish ./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz --ignore-scripts --provenance --access public --tag latest diff --git a/.gitignore b/.gitignore index 8b3353b..5e7fdb3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ test-results/ website/dist/ .vercel .env* +**/.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b3e3..d843bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ All notable changes to this project are documented here. -## 0.1.0-alpha.0 — Unreleased +## 1.0.0 — 2026-09-30 + +- Promote the existing anchored positioning API to a stable release with React + 18.3 and React 19 consumer verification. +- Preserve React 19 callback-ref cleanup and keep refs attached through ordinary + renders. Reject fragments as `asChild` anchors. +- Preserve anchor direction across portals for correct RTL alignment and + transform origins, including content narrower than its anchor. +- Use the SSR-safe layout effect for content bookkeeping. +- Validate installed declarations, module formats, open-root server rendering, + and CSS exports against both supported React versions. +- Stage retained package bytes through trusted publishing before approval and + verify current main, exact version, integrity, and package contents. +- Update the stable documentation, security support policy, and transitive + development dependency `brace-expansion` to its patched version. + +## 0.1.0-alpha.0 - Add controlled and uncontrolled anchored-layer roots. - Add dependency-free `asChild` anchor composition for React 18.3 and React 19. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f44399c..b15be53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,11 +13,32 @@ visibility. Interaction semantics belong in consuming applications. ## Releases -Run `npm run release:check` before preparing a release. Publishing starts only -when a GitHub Release is published and its tag exactly matches the package -version (for example, `v0.1.0-alpha.0`). The workflow runs the complete quality -gate and browser matrix, rejects versions that already exist on npm, and then -publishes the public package with provenance under the `alpha` dist-tag. - -The `npm` GitHub environment must be registered as the package's trusted -publisher on npm. Manual workflow dispatch verifies a tag but cannot publish. +Run `NPM_CONFIG_USERCONFIG=/dev/null npm run release:check` before preparing the +stable release. The release policy requires exactly `1.0.0`, public access, +provenance, and the `latest` dist-tag. A supplied release tag must be `v1.0.0`. + +After the release PR passes CI and is merged, manually dispatch +`.github/workflows/release.yml` from `main`. Keep this filename registered with +the npm trusted publisher, with the protected `npm` GitHub environment, and +enable its permission to stage packages. The workflow installs npm 11.19.0, +runs the full quality gate and the Chromium, Firefox, WebKit, and website +browser suites, and fails closed if the npm version lookup is unsuccessful or +the version already exists. It compares the checked-out commit, `GITHUB_SHA`, +and a fresh remote `refs/heads/main` lookup immediately before preparation and +again immediately before staging. If main moves, dispatch again from its new +commit. + +The workflow packs once and uploads the retained tarball, SHA-512 checksum, and +`release-artifact.json` containing its source commit and publication policy. It +rechecks those bytes, the embedded package manifest, and the exact package file +surface, and runs the React 18 and 19 runtime, SSR, type, and CSS consumer tests +on that exact retained tarball before uploading it. It then runs +`npm stage publish` on that tarball with public access, +provenance, and `latest`. GitHub Releases do not trigger staging or publication. + +Before approving the staged package, download its bytes and compare their +SHA-512 checksum to the workflow artifact. Check the source commit, package +identity, version, and provenance. Approve the exact stage through npm's 2FA +approval flow only after that review. Record the stage ID and integrity alongside +the release evidence, then create the `v1.0.0` tag and GitHub Release at that +verified source commit. This repository's workflow never publishes directly. diff --git a/README.md b/README.md index e209cdf..79662b3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Anchored floating layers for React. -This repository is under active development toward `0.1.0-alpha.0`. +Version `1.0.0` is the stable release. React Anchored Layer renders arbitrary content through a portal and keeps it aligned with an anchor through scrolling, resizing, and layout changes. It does @@ -15,11 +15,11 @@ positioning engine. ## Installation ```sh -npm install @nipe-solutions/react-anchored-layer +npm install --save-exact @nipe-solutions/react-anchored-layer ``` React and React DOM are peer dependencies. React 18.3 and React 19 are -supported. +supported. The package currently requires Node 24 for installation and development. ## Quick start @@ -68,15 +68,19 @@ the following CSS variables: Module import and server rendering are safe without DOM globals. Portal content is established on the client after mounting. +See the [API reference](docs/API.md) for prop defaults, ref behavior, and portal +lifecycle details. Applications should render one anchor and one content per +root. Child components used with `asChild` must forward a ref to an HTMLElement. + ## Compatibility and size The package targets current Chromium, Firefox, and WebKit, with automated Playwright coverage in all three engines. The published JavaScript is measured with React, React DOM, and Floating UI external: the current build is about -2.2 kB gzip for ESM and 1.8 kB gzip for CommonJS. The packed prerelease artifact -is about 7.4 kB. +2.5 kB gzip for ESM and 2.1 kB gzip for CommonJS. The packed artifact is kept +below 15 kB. -Known initial limitations are deliberate: there are no virtual anchors, arrow, +The scope is deliberate: there are no virtual anchors, arrow, raw middleware API, automatic offscreen dismissal, vertical-writing-mode claim, or global stacking coordinator. @@ -85,8 +89,10 @@ or global stacking coordinator. Use Node 24 and npm 11. ```sh -npm install +npm ci npm run check +npm run test:e2e +npm run test:website:e2e ``` ## License diff --git a/SECURITY.md b/SECURITY.md index 0bd96c7..6ad9f2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -This project is in prerelease development. Security fixes target the latest -published prerelease. +Security fixes target the latest stable release in the `1.x` series. Upgrade +to the current `latest` version before reporting a suspected vulnerability. ## Reporting a vulnerability diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..fb3bf11 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,86 @@ +# API reference + +The five components are available as named exports and through the frozen +`AnchoredLayer` namespace. React and React DOM 18.3 and 19 are supported. +Use one `Anchor` and one `Content` per `Root`; independent layers need independent +roots. Import `core.css` for positioning mechanics. + +## Root + +| Prop | Default | Meaning | +| -------------- | ------------ | ----------------------------------------------------------------------------------------------------------- | +| `open` | Uncontrolled | Controlled visibility. A requested change invokes `onOpenChange` without changing this prop. | +| `defaultOpen` | `false` | Initial uncontrolled visibility; subsequent prop changes do not reset it. | +| `onOpenChange` | None | Notification for a requested visibility change. Root does not add trigger, dismissal, or keyboard behavior. | + +Choose controlled or uncontrolled state for the lifetime of a root. Closed +content unmounts, including its positioning subscriptions and refs. + +## Anchor + +The default anchor is a `span`. `asChild` uses exactly one element instead. +Pass a DOM element or a component that forwards its ref to an HTMLElement; +strings and fragments cannot register an anchor. The child's existing props +take precedence over props on `Anchor`, including event handlers. Event handlers +are not automatically combined. + +The child ref, forwarded anchor ref, and internal registration are composed. +Unchanged refs remain attached during ordinary renders. React 19 callback-ref +cleanup runs on detach; refs without cleanup receive `null`. Replacing the anchor +hides content until positioning for the new element is established. + +## Content + +Content forwards its ref to a `div` and accepts normal div attributes, roles, +ARIA attributes, events, and styles. Positioning and first-position visibility +styles take precedence over conflicting consumer styles. + +| Prop | Default | Meaning | +| ------------------- | ------------------------------ | ------------------------------------------------------------------------------- | +| `placement` | `bottom-start` | `top`, `right`, `bottom`, or `left`, optionally followed by `-start` or `-end`. | +| `strategy` | `absolute` | CSS positioning strategy; `fixed` is also available. | +| `offset` | `4` | Pixel gap, or `{ mainAxis, crossAxis }` offsets. | +| `avoidCollisions` | `true` | Flip and shift against clipping boundaries. | +| `collisionBoundary` | Floating UI clipping ancestors | One HTMLElement or an array of elements. | +| `collisionPadding` | `8` | Pixel inset from the collision boundary. | +| `matchAnchorWidth` | `false` | Apply measured anchor width, overriding a consumer width. | + +Keep numeric geometry options finite. Supply connected elements for explicit +collision boundaries. Width matching and available-size variables do not add +scrolling or limit the content's height: set `max-height` and `overflow` as needed. + +Content inherits the anchor's text direction across its portal unless an explicit +`dir` or directional style is supplied. Start/end and transform origin follow +that direction. Vertical writing modes are outside the supported contract. + +Content is omitted while closed or without an anchor. Its first unpositioned +render is hidden and ignores pointer events. Once positioned it exposes +`data-positioned="true"`, `data-state="open"`, `data-side`, and `data-align`. + +Measured CSS variables: + +- `--anchored-layer-anchor-width` +- `--anchored-layer-anchor-height` +- `--anchored-layer-available-width` +- `--anchored-layer-available-height` +- `--anchored-layer-transform-origin` + +## Portal and Provider + +Content uses a body portal unless wrapped in an explicit `Portal`. The destination +is the Portal's `container`, then the nearest Provider's `portalRoot`, then +`document.body`. An undefined or null custom root falls back to the next choice. +A detached explicit root suppresses content instead of switching to another root. +After attaching or replacing a root, rerender the component so it can resolve the +destination again. The portal does not observe external removal of its container. + +React context is preserved across a portal. DOM inheritance and modal focus +boundaries depend on the destination; choose a root within the modal when needed. +Server rendering omits portal children and establishes them after client mount. + +## Application responsibilities + +The package supplies no automatic role, focus movement, outside-press handling, +Escape dismissal, keyboard selection, or offscreen dismissal. Applications own +these behaviors and the semantics of the surface they render. Virtual anchors, +arrows, raw middleware, and global stacking coordination are not provided. diff --git a/package-lock.json b/package-lock.json index 880858a..a514b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nipe-solutions/react-anchored-layer", - "version": "0.1.0-alpha.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nipe-solutions/react-anchored-layer", - "version": "0.1.0-alpha.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.9" @@ -1462,9 +1462,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 119de74..93ad29d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nipe-solutions/react-anchored-layer", - "version": "0.1.0-alpha.0", + "version": "1.0.0", "description": "Anchored floating layers for React.", "homepage": "https://react-anchored-layer.nipesolutions.com", "repository": { @@ -39,7 +39,7 @@ "publishConfig": { "access": "public", "provenance": true, - "tag": "alpha" + "tag": "latest" }, "scripts": { "test": "vitest run", @@ -57,7 +57,7 @@ "test:website": "node scripts/verify-website.mjs", "test:website:e2e": "playwright test --config playwright.website.config.ts", "test:e2e": "playwright test", - "test:workflows": "node --test scripts/verify-workflows.test.mjs scripts/verify-release.test.mjs && node scripts/verify-workflows.mjs", + "test:workflows": "node --test scripts/verify-workflows.test.mjs scripts/verify-release.test.mjs scripts/pack-artifact.test.mjs && node scripts/verify-workflows.mjs", "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm run test:types && npm run build:dist && npm run test:api && npm run test:size && npm run test:package && npm run build:website && npm run test:website && npm run test:workflows", "release:check": "npm run check && node scripts/verify-release.mjs --dry-run" }, diff --git a/scripts/pack-artifact.mjs b/scripts/pack-artifact.mjs index 8f9a04c..791caf7 100644 --- a/scripts/pack-artifact.mjs +++ b/scripts/pack-artifact.mjs @@ -1,11 +1,237 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, renameSync } from 'node:fs' -import { resolve } from 'node:path' - -const output = JSON.parse( - execFileSync('npm', ['pack', '--json'], { encoding: 'utf8' }), -)[0] -mkdirSync('artifacts', { recursive: true }) -const destination = resolve('artifacts', output.filename) -renameSync(resolve(output.filename), destination) -process.stdout.write(`${destination}\n`) +import { createHash } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + npmEnvironment, + releaseName, + releaseTarball, + validateReleaseMetadata, +} from './verify-release.mjs' + +const packageFiles = [ + 'CHANGELOG.md', + 'LICENSE', + 'README.md', + 'dist/core.css', + 'dist/index.cjs', + 'dist/index.d.ts', + 'dist/index.js', + 'dist/styles.css', + 'dist/theme.css', + 'package.json', +] +const metadataKeys = [ + 'access', + 'integrity', + 'name', + 'provenance', + 'sourceSha', + 'tag', + 'tarball', + 'version', +] + +function requireSourceSha(sourceSha) { + if (typeof sourceSha !== 'string' || !/^[a-f0-9]{40}$/.test(sourceSha)) + throw new Error('Artifact requires a valid source SHA') +} + +function requireFileSurface(files) { + if ( + !Array.isArray(files) || + JSON.stringify([...files].sort()) !== JSON.stringify(packageFiles) + ) + throw new Error( + 'Release package file surface must match the exact public package files', + ) +} + +function requireIntegrity(integrity) { + const digest = + typeof integrity === 'string' && + /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(integrity)?.[1] + if ( + !digest || + Buffer.from(digest, 'base64').length !== 64 || + Buffer.from(digest, 'base64').toString('base64') !== digest + ) + throw new Error('Artifact integrity requires canonical SHA-512 SRI') +} + +export function validatePackDescriptor(descriptors) { + if (!Array.isArray(descriptors) || descriptors.length !== 1) + throw new Error('npm pack must return exactly one descriptor') + const descriptor = descriptors[0] + if ( + descriptor?.name !== releaseName || + descriptor?.version !== '1.0.0' || + descriptor?.filename !== releaseTarball + ) + throw new Error('npm pack identity, version or filename mismatch') + requireFileSurface(descriptor.files?.map((file) => file.path)) + requireIntegrity(descriptor.integrity) + return descriptor +} + +function requirePackageMetadata(metadata) { + const issues = validateReleaseMetadata(metadata) + if (issues.length) throw new Error(issues.join('\n')) +} + +function inspectArchive(tarball, run) { + const options = { encoding: 'utf8' } + const paths = run('tar', ['-tzf', tarball], options).trimEnd().split('\n') + requireFileSurface( + paths.map((path) => (path.startsWith('package/') ? path.slice(8) : path)), + ) + requirePackageMetadata( + JSON.parse(run('tar', ['-xOf', tarball, 'package/package.json'], options)), + ) +} + +function hashes(bytes) { + return { + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + checksum: `${createHash('sha512').update(bytes).digest('hex')} ${releaseTarball}\n`, + } +} + +export function prepareArtifact({ + root = process.cwd(), + sourceSha = process.env.GITHUB_SHA ?? + execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + run = execFileSync, +} = {}) { + requireSourceSha(sourceSha) + requirePackageMetadata( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')), + ) + const directory = join(root, 'artifacts') + mkdirSync(directory) + try { + const descriptor = validatePackDescriptor( + JSON.parse( + run( + 'npm', + [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + directory, + ], + { cwd: root, encoding: 'utf8', env: npmEnvironment() }, + ), + ), + ) + const tarball = join(directory, releaseTarball) + const { integrity, checksum } = hashes(readFileSync(tarball)) + if (integrity !== descriptor.integrity) + throw new Error('Packed tarball integrity differs from npm descriptor') + inspectArchive(tarball, run) + const metadata = { + name: releaseName, + version: '1.0.0', + tarball: releaseTarball, + sourceSha, + integrity, + tag: 'latest', + access: 'public', + provenance: true, + } + writeFileSync(join(directory, `${releaseTarball}.sha512`), checksum, { + flag: 'wx', + }) + writeFileSync( + join(directory, 'release-artifact.json'), + `${JSON.stringify(metadata, null, 2)}\n`, + { flag: 'wx' }, + ) + verifyRetainedArtifact({ root, sourceSha, run }) + return metadata + } catch (error) { + // This invocation owns the newly created directory; never remove earlier artifacts. + rmSync(directory, { recursive: true, force: true }) + throw error + } +} + +export function verifyRetainedArtifact({ + root = process.cwd(), + sourceSha = process.env.GITHUB_SHA, + run = execFileSync, +} = {}) { + requireSourceSha(sourceSha) + requirePackageMetadata( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')), + ) + const directory = join(root, 'artifacts') + const expectedFiles = [ + releaseTarball, + `${releaseTarball}.sha512`, + 'release-artifact.json', + ].sort() + if ( + JSON.stringify(readdirSync(directory).sort()) !== + JSON.stringify(expectedFiles) + ) + throw new Error( + 'Artifact must contain exactly the tarball, checksum and metadata', + ) + for (const filename of expectedFiles) + if (!lstatSync(join(directory, filename)).isFile()) + throw new Error('Artifact entries must be regular files') + const metadata = JSON.parse( + readFileSync(join(directory, 'release-artifact.json'), 'utf8'), + ) + if ( + !metadata || + Array.isArray(metadata) || + JSON.stringify(Object.keys(metadata).sort()) !== + JSON.stringify(metadataKeys) + ) + throw new Error('Invalid artifact metadata fields') + if (metadata.sourceSha !== sourceSha) + throw new Error('Retained artifact source SHA mismatch') + if ( + metadata.name !== releaseName || + metadata.version !== '1.0.0' || + metadata.tarball !== releaseTarball || + metadata.tag !== 'latest' || + metadata.access !== 'public' || + metadata.provenance !== true + ) + throw new Error('Retained artifact publication policy mismatch') + requireIntegrity(metadata.integrity) + const tarball = join(directory, releaseTarball) + const { integrity, checksum } = hashes(readFileSync(tarball)) + if (integrity !== metadata.integrity) + throw new Error('Retained tarball integrity mismatch') + if ( + readFileSync(join(directory, `${releaseTarball}.sha512`), 'utf8') !== + checksum + ) + throw new Error('Retained artifact checksum mismatch') + inspectArchive(tarball, run) + return metadata +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const args = process.argv.slice(2) + if (args.length === 0) { + const metadata = prepareArtifact() + process.stdout.write(`${resolve('artifacts', metadata.tarball)}\n`) + } else if (args.length === 1 && args[0] === '--verify-retained') { + verifyRetainedArtifact() + process.stdout.write('Retained artifact bytes and policy verified.\n') + } else throw new Error('Usage: pack-artifact.mjs [--verify-retained]') +} diff --git a/scripts/pack-artifact.test.mjs b/scripts/pack-artifact.test.mjs new file mode 100644 index 0000000..a4f0b60 --- /dev/null +++ b/scripts/pack-artifact.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import * as artifact from './pack-artifact.mjs' + +const sha = 'a'.repeat(40) +const metadata = { + name: '@nipe-solutions/react-anchored-layer', + version: '1.0.0', + publishConfig: { access: 'public', provenance: true, tag: 'latest' }, + files: ['dist', 'CHANGELOG.md', 'LICENSE'], +} + +function fixture(run) { + const root = mkdtempSync(join(tmpdir(), 'anchored-release-')) + mkdirSync(join(root, 'dist')) + writeFileSync(join(root, 'package.json'), JSON.stringify(metadata)) + for (const path of [ + 'README.md', + 'LICENSE', + 'CHANGELOG.md', + 'dist/index.js', + 'dist/index.cjs', + 'dist/index.d.ts', + 'dist/core.css', + 'dist/theme.css', + 'dist/styles.css', + ]) + writeFileSync(join(root, path), 'fixture\n') + try { + run(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('packs and rechecks the same retained bytes with exact source identity', () => + fixture((root) => { + const release = artifact.prepareArtifact({ root, sourceSha: sha }) + assert.equal( + release.tarball, + 'nipe-solutions-react-anchored-layer-1.0.0.tgz', + ) + assert.equal(release.sourceSha, sha) + assert.equal(release.tag, 'latest') + assert.deepEqual( + artifact.verifyRetainedArtifact({ root, sourceSha: sha }), + release, + ) + assert.match( + readFileSync( + join(root, 'artifacts', `${release.tarball}.sha512`), + 'utf8', + ), + /^[a-f0-9]{128} {2}nipe-solutions-react-anchored-layer-1\.0\.0\.tgz\n$/, + ) + assert.throws( + () => artifact.prepareArtifact({ root, sourceSha: sha }), + /EEXIST|preexisting/, + ) + })) + +test('retained artifact rejects mutated bytes, checksums, identity and extra files', () => + fixture((root) => { + const release = artifact.prepareArtifact({ root, sourceSha: sha }) + const directory = join(root, 'artifacts') + const tarball = join(directory, release.tarball) + const original = readFileSync(tarball) + writeFileSync(tarball, 'altered bytes') + assert.throws( + () => artifact.verifyRetainedArtifact({ root, sourceSha: sha }), + /integrity/, + ) + writeFileSync(tarball, original) + assert.throws( + () => + artifact.verifyRetainedArtifact({ root, sourceSha: 'b'.repeat(40) }), + /source/, + ) + const checksum = join(directory, `${release.tarball}.sha512`) + const originalChecksum = readFileSync(checksum) + writeFileSync( + checksum, + originalChecksum.toString() + originalChecksum.toString(), + ) + assert.throws( + () => artifact.verifyRetainedArtifact({ root, sourceSha: sha }), + /checksum/, + ) + writeFileSync(checksum, originalChecksum) + writeFileSync(join(directory, 'extra'), 'unsafe') + assert.throws( + () => artifact.verifyRetainedArtifact({ root, sourceSha: sha }), + /exactly/, + ) + })) + +test('packing rejects unexpected package files and refuses unsafe source SHA', () => + fixture((root) => { + writeFileSync(join(root, 'dist', 'secret.txt'), 'must not ship') + assert.throws( + () => artifact.prepareArtifact({ root, sourceSha: sha }), + /file surface/, + ) + assert.throws( + () => artifact.prepareArtifact({ root, sourceSha: 'bad\nsha' }), + /source SHA/, + ) + })) + +test('pack descriptor validation rejects wrong identity and malformed integrity', () => { + const descriptor = { + name: metadata.name, + version: '1.0.0', + filename: 'nipe-solutions-react-anchored-layer-1.0.0.tgz', + integrity: 'sha512-' + Buffer.alloc(64).toString('base64'), + files: [ + 'CHANGELOG.md', + 'LICENSE', + 'README.md', + 'dist/core.css', + 'dist/index.cjs', + 'dist/index.d.ts', + 'dist/index.js', + 'dist/styles.css', + 'dist/theme.css', + 'package.json', + ].map((path) => ({ path })), + } + assert.doesNotThrow(() => artifact.validatePackDescriptor([descriptor])) + for (const patch of [ + { name: '@other/package' }, + { version: '1.0.1' }, + { filename: '../escape.tgz' }, + { integrity: 'sha512-YWJjZA==' }, + { files: [...descriptor.files, descriptor.files[0]] }, + ]) + assert.throws(() => + artifact.validatePackDescriptor([{ ...descriptor, ...patch }]), + ) + for (const descriptors of [[], [descriptor, descriptor], {}]) + assert.throws( + () => artifact.validatePackDescriptor(descriptors), + /exactly one/, + ) +}) + +test('an npm pack process failure cannot leave an approvable artifact', () => + fixture((root) => { + assert.throws( + () => + artifact.prepareArtifact({ + root, + sourceSha: sha, + run: (command, args, options) => { + if (command === 'npm') throw new Error('pack failed') + return execFileSync(command, args, options) + }, + }), + /pack failed/, + ) + assert.throws( + () => artifact.verifyRetainedArtifact({ root, sourceSha: sha }), + /ENOENT|exactly/, + ) + })) diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index f41d8c3..7eaad11 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -4,8 +4,8 @@ import { pathToFileURL } from 'node:url' export function validateRelease(version, tag) { const issues = [] - if (!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(version)) { - issues.push('Initial releases must use an alpha prerelease version') + if (version !== '1.0.0') { + issues.push('This release requires exactly stable version 1.0.0') } if (tag !== undefined && tag !== `v${version}`) { issues.push(`Tag ${tag} does not match package version ${version}`) @@ -13,39 +13,149 @@ export function validateRelease(version, tag) { return issues } +export const releaseName = '@nipe-solutions/react-anchored-layer' +export const releaseTarball = 'nipe-solutions-react-anchored-layer-1.0.0.tgz' + +export function validateReleaseMetadata(metadata, tag) { + const issues = validateRelease(metadata.version, tag) + if (metadata.name !== releaseName) + issues.push('Unexpected release package name') + if (metadata.private === true) + issues.push('Release package must not be private') + if (metadata.publishConfig?.provenance !== true) + issues.push('npm provenance must be enabled') + if (metadata.publishConfig?.access !== 'public') + issues.push('package access must be public') + if (metadata.publishConfig?.tag !== 'latest') + issues.push('npm dist-tag must be latest') + return issues +} + +export function npmEnvironment() { + return { ...process.env, NPM_CONFIG_USERCONFIG: '/dev/null' } +} + export function ensureVersionIsUnpublished(name, version, run = spawnSync) { + if (name !== releaseName || version !== '1.0.0') + throw new Error('Unexpected registry package identity or version') const result = run( 'npm', - ['view', `${name}@${version}`, 'version', '--json'], - { encoding: 'utf8' }, + [ + 'view', + `${name}@${version}`, + 'version', + '--json', + '--registry=https://registry.npmjs.org', + ], + { encoding: 'utf8', env: npmEnvironment(), timeout: 30_000 }, ) if (result.status === 0) { throw new Error(`${name}@${version} already exists on npm`) } - if (!String(result.stderr).includes('E404')) { + let errorCode + try { + errorCode = JSON.parse(result.stdout)?.error?.code + } catch { + /* An ambiguous lookup must fail closed. */ + } + if ( + result.status !== 1 || + result.error || + result.signal || + errorCode !== 'E404' + ) { throw new Error( `Could not verify npm publication state: ${String(result.stderr).trim()}`, ) } } -export async function verifyRelease() { - const metadata = JSON.parse(await readFile('package.json', 'utf8')) - const tagArgument = process.argv.find((argument) => - argument.startsWith('--tag='), +export function verifyCurrentMain({ + env = process.env, + run = execFileSync, +} = {}) { + if ( + env.GITHUB_REF !== 'refs/heads/main' || + env.GITHUB_EVENT_NAME !== 'workflow_dispatch' ) - const tag = tagArgument?.slice('--tag='.length) - const issues = validateRelease(metadata.version, tag) - if (!metadata.publishConfig?.provenance) - issues.push('npm provenance must be enabled') - if (metadata.publishConfig?.access !== 'public') - issues.push('package access must be public') - if (metadata.publishConfig?.tag !== 'alpha') - issues.push('npm dist-tag must be alpha') + throw new Error('Current main requires a manual dispatch from main') + if ( + typeof env.GITHUB_SHA !== 'string' || + !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA) + ) + throw new Error('Current main requires a valid GitHub SHA') + let head, remote + try { + head = run('git', ['rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' }) + remote = run( + 'git', + ['ls-remote', '--exit-code', 'origin', 'refs/heads/main'], + { encoding: 'utf8', timeout: 30_000 }, + ) + } catch (error) { + throw new Error('Current main lookup failed', { cause: error }) + } + const checkedOut = + typeof head === 'string' + ? /^([a-f0-9]{40})\r?\n?$/.exec(head)?.[1] + : undefined + const currentMain = + typeof remote === 'string' + ? /^([a-f0-9]{40})\trefs\/heads\/main\r?\n?$/.exec(remote)?.[1] + : undefined + if (!checkedOut || !currentMain) + throw new Error( + 'Current main requires exactly one HEAD and remote refs/heads/main commit', + ) + if (checkedOut !== env.GITHUB_SHA || currentMain !== checkedOut) + throw new Error( + 'Current main rejected a stale or mismatched checkout; dispatch again from current main', + ) + return checkedOut +} + +export function parseArguments(args) { + const options = { + dryRun: false, + ensureUnpublished: false, + tag: undefined, + currentMain: false, + } + const seen = new Set() + for (const argument of args) { + const key = argument.startsWith('--tag=') ? '--tag' : argument + if (seen.has(key)) throw new Error('Duplicate release arguments') + seen.add(key) + if (argument === '--dry-run') options.dryRun = true + else if (argument === '--ensure-unpublished') + options.ensureUnpublished = true + else if (argument === '--verify-current-main') options.currentMain = true + else if (key === '--tag' && /^--tag=v1\.0\.0$/.test(argument)) + options.tag = argument.slice(6) + else throw new Error('Invalid release arguments or tag') + } + if (options.currentMain && args.length !== 1) + throw new Error('Current main arguments cannot be combined') + return options +} + +export async function verifyRelease( + options = parseArguments(process.argv.slice(2)), +) { + if (options.currentMain) { + verifyCurrentMain() + return + } + const metadata = JSON.parse(await readFile('package.json', 'utf8')) + const issues = validateReleaseMetadata(metadata, options.tag) if (issues.length > 0) throw new Error(issues.join('\n')) - if (process.argv.includes('--ensure-unpublished')) + if (options.ensureUnpublished) ensureVersionIsUnpublished(metadata.name, metadata.version) - execFileSync('npm', ['pack', '--dry-run', '--json'], { stdio: 'pipe' }) + if (options.dryRun) + execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + stdio: 'pipe', + env: npmEnvironment(), + }) process.stdout.write(`Release dry run verified for ${metadata.version}.\n`) } diff --git a/scripts/verify-release.test.mjs b/scripts/verify-release.test.mjs index d2235b8..334d0f7 100644 --- a/scripts/verify-release.test.mjs +++ b/scripts/verify-release.test.mjs @@ -1,50 +1,178 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import * as release from './verify-release.mjs' -import { - ensureVersionIsUnpublished, - validateRelease, -} from './verify-release.mjs' +const name = '@nipe-solutions/react-anchored-layer' +const sha = 'a'.repeat(40) +const metadata = { + name, + version: '1.0.0', + publishConfig: { access: 'public', provenance: true, tag: 'latest' }, +} -test('accepts the alpha package version and matching tag', () => { - assert.deepEqual(validateRelease('0.1.0-alpha.0', 'v0.1.0-alpha.0'), []) +test('accepts exactly the stable version and matching optional tag', () => { + assert.deepEqual(release.validateRelease('1.0.0', 'v1.0.0'), []) + assert.deepEqual(release.validateRelease('1.0.0'), []) + for (const version of ['0.1.0-alpha.0', '1.0.1', '01.0.0', '1.0.0+build']) + assert.ok(release.validateRelease(version).length > 0) + assert.ok(release.validateRelease('1.0.0', 'v1.0.1').length > 0) }) -test('rejects stable versions and mismatched tags', () => { - assert.deepEqual(validateRelease('1.0.0', 'v0.1.0-alpha.0'), [ - 'Initial releases must use an alpha prerelease version', - 'Tag v0.1.0-alpha.0 does not match package version 1.0.0', +test('publication policy rejects wrong identity and unsafe configuration', () => { + assert.deepEqual(release.validateReleaseMetadata(metadata), []) + for (const patch of [ + { name: '@other/package' }, + { private: true }, + { publishConfig: { ...metadata.publishConfig, tag: 'alpha' } }, + { publishConfig: { ...metadata.publishConfig, provenance: 'true' } }, + { publishConfig: { ...metadata.publishConfig, access: 'restricted' } }, ]) + assert.ok( + release.validateReleaseMetadata({ ...metadata, ...patch }).length > 0, + ) }) -test('rejects a version that already exists on npm', () => { +test('registry guard accepts only a structured version E404', () => { + const absent = { + status: 1, + stdout: JSON.stringify({ error: { code: 'E404' } }), + stderr: 'npm error code E404', + } + assert.doesNotThrow(() => + release.ensureVersionIsUnpublished(name, '1.0.0', () => absent), + ) assert.throws( () => - ensureVersionIsUnpublished( - '@nipe-solutions/react-anchored-layer', - '0.1.0-alpha.0', - () => ({ status: 0, stdout: '"0.1.0-alpha.0"\n', stderr: '' }), - ), - /already exists on npm/, + release.ensureVersionIsUnpublished(name, '1.0.0', () => ({ + status: 0, + stdout: '"1.0.0"', + stderr: '', + })), + /already exists/, ) + for (const result of [ + { status: 1, stdout: '', stderr: 'timeout mentions E404' }, + { status: 1, stdout: '{broken', stderr: 'E404' }, + { status: null, signal: 'SIGTERM', stdout: absent.stdout, stderr: '' }, + { ...absent, error: new Error('spawn failed') }, + { + status: 1, + stdout: JSON.stringify({ error: { code: 'E503' } }), + stderr: '', + }, + ]) + assert.throws( + () => release.ensureVersionIsUnpublished(name, '1.0.0', () => result), + /Could not verify/, + ) }) -test('accepts an npm 404 and rejects registry failures', () => { - assert.doesNotThrow(() => - ensureVersionIsUnpublished( - '@nipe-solutions/react-anchored-layer', - '0.1.0-alpha.0', - () => ({ status: 1, stdout: '', stderr: 'npm error code E404' }), - ), +test('registry lookup pins the public registry and ignores user npm configuration', () => { + release.ensureVersionIsUnpublished( + name, + '1.0.0', + (command, args, options) => { + assert.equal(command, 'npm') + assert.deepEqual(args, [ + 'view', + `${name}@1.0.0`, + 'version', + '--json', + '--registry=https://registry.npmjs.org', + ]) + assert.equal(options.env.NPM_CONFIG_USERCONFIG, '/dev/null') + return { status: 1, stdout: '{"error":{"code":"E404"}}', stderr: '' } + }, ) +}) +test('current main guard compares exact HEAD, dispatched SHA and fresh remote ref', () => { + const env = { + GITHUB_SHA: sha, + GITHUB_REF: 'refs/heads/main', + GITHUB_EVENT_NAME: 'workflow_dispatch', + } + const run = (command, args) => { + assert.equal(command, 'git') + return args[0] === 'rev-parse' ? `${sha}\n` : `${sha}\trefs/heads/main\n` + } + assert.equal(release.verifyCurrentMain({ env, run }), sha) + for (const remote of [ + `${'b'.repeat(40)}\trefs/heads/main\n`, + `${sha}\trefs/heads/not-main\n`, + `${sha}\trefs/heads/main\n${sha}\trefs/heads/main\n`, + '', + ]) + assert.throws( + () => + release.verifyCurrentMain({ + env, + run: (_command, args) => + args[0] === 'rev-parse' ? `${sha}\n` : remote, + }), + /Current main/, + ) + assert.throws( + () => + release.verifyCurrentMain({ + env, + run: () => { + throw new Error('network failed') + }, + }), + /lookup failed/, + ) + assert.throws( + () => + release.verifyCurrentMain({ env: { ...env, GITHUB_SHA: 'bad' }, run }), + /SHA/, + ) assert.throws( () => - ensureVersionIsUnpublished( - '@nipe-solutions/react-anchored-layer', - '0.1.0-alpha.0', - () => ({ status: 1, stdout: '', stderr: 'network timeout' }), - ), - /Could not verify npm publication state/, + release.verifyCurrentMain({ + env: { ...env, GITHUB_REF: 'refs/heads/feature' }, + run, + }), + /manual dispatch from main/, ) + assert.throws( + () => + release.verifyCurrentMain({ + env: { ...env, GITHUB_EVENT_NAME: 'release' }, + run, + }), + /manual dispatch from main/, + ) + assert.throws( + () => + release.verifyCurrentMain({ + env: { ...env, GITHUB_SHA: 'b'.repeat(40) }, + run, + }), + /stale|mismatched/, + ) +}) + +test('release arguments reject accidental publishing and ambiguous forms', () => { + assert.deepEqual(release.parseArguments(['--dry-run']), { + dryRun: true, + ensureUnpublished: false, + tag: undefined, + currentMain: false, + }) + assert.equal( + release.parseArguments(['--verify-current-main']).currentMain, + true, + ) + assert.equal( + release.parseArguments(['--ensure-unpublished']).ensureUnpublished, + true, + ) + for (const args of [ + ['--publish'], + ['--tag='], + ['--tag=v1.0.0', '--tag=v1.0.1'], + ['--verify-current-main', '--dry-run'], + ]) + assert.throws(() => release.parseArguments(args), /arguments|tag/) }) diff --git a/scripts/verify-workflows.mjs b/scripts/verify-workflows.mjs index 80249a5..2fc6f7a 100644 --- a/scripts/verify-workflows.mjs +++ b/scripts/verify-workflows.mjs @@ -1,36 +1,133 @@ import { readFile } from 'node:fs/promises' import { pathToFileURL } from 'node:url' +// The release contract deliberately accepts only plain mappings and scalar step +// commands. YAML aliases, duplicate keys and unsupported executable extensions +// fail closed instead of bypassing checks through textual matches. +function releaseFields(source) { + const fields = new Map() + const stack = [] + const indices = new Map() + for (const line of source.split(/\r?\n/)) { + if (!line.trim() || /^\s*#/.test(line)) continue + const match = /^( *)(- )?([A-Za-z][\w-]*):(?: (.*))?$/.exec(line.trimEnd()) + if (!match) throw new Error('Unsupported release workflow syntax') + let indent = match[1].length + if (indent % 2) throw new Error('Invalid workflow indentation') + while (stack.length && stack.at(-1).indent >= indent) stack.pop() + if (indent !== (stack.length ? stack.at(-1).indent + 2 : 0)) + throw new Error('Invalid workflow nesting') + let parent = stack.at(-1)?.path + if (match[2]) { + if (parent !== 'jobs.stage.steps') + throw new Error('Only release steps may be lists') + const index = indices.get(parent) ?? 0 + indices.set(parent, index + 1) + parent = `${parent}[${index}]` + stack.push({ indent, path: parent }) + indent += 2 + } + const path = parent ? `${parent}.${match[3]}` : match[3] + if (fields.has(path)) throw new Error(`Duplicate workflow key: ${path}`) + const value = match[4] ?? null + if (value !== null && /(^|\s)[&*][\w-]+/.test(value)) + throw new Error('Workflow aliases are not allowed') + fields.set(path, value) + if (value === null) stack.push({ indent, path }) + } + return fields +} + export function validateReleaseWorkflow(source) { - const issues = [] - if (!/release:\s*\n\s+types:\s*\[published\]/.test(source)) - issues.push('release.published trigger is required') - if (/workflow_dispatch:[\s\S]*?inputs:[\s\S]*?publish:/.test(source)) - issues.push('manual dispatch must not accept a publish input') - if ( - !/if:\s*\$\{\{ github\.event_name == 'release' \}\}[\s\S]*?npm publish/.test( - source, - ) - ) - issues.push('publish step must be restricted to the release event') - if (!/ref:\s*\$\{\{ env\.RELEASE_TAG \}\}/.test(source)) - issues.push('release tag must be checked out explicitly') - if (!/--ensure-unpublished/.test(source)) - issues.push('duplicate npm versions must be rejected') - if (!/test:e2e/.test(source) || !/test:website:e2e/.test(source)) - issues.push('full browser matrix must run before publishing') - if ( - !/group:\s*npm-publish-\$\{\{ github\.event\.release\.tag_name \|\| inputs\.tag \}\}/.test( - source, - ) + let fields + try { + fields = releaseFields(source) + } catch (error) { + return [error.message] + } + const expected = new Map( + Object.entries({ + name: 'Stage stable package', + on: null, + 'on.workflow_dispatch': null, + env: null, + 'env.NPM_CONFIG_USERCONFIG': '/dev/null', + concurrency: null, + 'concurrency.group': 'npm-stage-react-anchored-layer-stable', + 'concurrency.cancel-in-progress': 'false', + permissions: null, + 'permissions.contents': 'read', + jobs: null, + 'jobs.stage': null, + 'jobs.stage.if': "github.ref == 'refs/heads/main'", + 'jobs.stage.environment': 'npm', + 'jobs.stage.runs-on': 'ubuntu-latest', + 'jobs.stage.timeout-minutes': '60', + 'jobs.stage.permissions': null, + 'jobs.stage.permissions.contents': 'read', + 'jobs.stage.permissions.id-token': 'write', + 'jobs.stage.steps': null, + }), ) - issues.push('per-tag publish concurrency is required') - if (!/id-token:\s*write/.test(source)) - issues.push('npm trusted publishing requires id-token: write') - if (!/npm publish --provenance --access public --tag alpha/.test(source)) - issues.push('npm publish flags must be explicit') - if (!/package-manager-cache:\s*false/.test(source)) - issues.push('release dependency caching must be disabled') + const steps = [ + { + uses: 'actions/checkout@v7', + with: { ref: '${{ github.sha }}', 'fetch-depth': '0' }, + }, + { + uses: 'actions/setup-node@v7', + with: { + 'node-version': '24', + 'registry-url': 'https://registry.npmjs.org', + 'package-manager-cache': 'false', + }, + }, + { run: 'npm install --global npm@11.19.0' }, + { run: 'npm ci' }, + { run: 'npm run check' }, + { run: 'npx playwright install --with-deps chromium firefox webkit' }, + { run: 'npm run test:e2e' }, + { run: 'npm run test:website:e2e' }, + { run: 'node scripts/verify-release.mjs --ensure-unpublished' }, + { run: 'node scripts/verify-release.mjs --verify-current-main' }, + { run: 'node scripts/pack-artifact.mjs' }, + { + run: 'ANCHORED_LAYER_PACKAGE_TARBALL=./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz npm run test:package', + }, + { + uses: 'actions/upload-artifact@v7', + with: { + name: 'npm-react-anchored-layer-1.0.0-${{ github.sha }}', + path: 'artifacts/', + 'if-no-files-found': 'error', + 'retention-days': '7', + }, + }, + { run: 'node scripts/pack-artifact.mjs --verify-retained' }, + { run: 'node scripts/verify-release.mjs --ensure-unpublished' }, + { run: 'node scripts/verify-release.mjs --verify-current-main' }, + { + run: 'npm stage publish ./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz --ignore-scripts --provenance --access public --tag latest', + }, + ] + for (const [index, step] of steps.entries()) { + const path = `jobs.stage.steps[${index}]` + for (const [key, value] of Object.entries(step)) { + if (key === 'with') { + expected.set(`${path}.with`, null) + for (const [option, setting] of Object.entries(value)) + expected.set(`${path}.with.${option}`, setting) + } else expected.set(`${path}.${key}`, value) + } + } + const issues = [] + for (const [path, value] of expected) { + if (!fields.has(path) || fields.get(path) !== value) + issues.push(`Release workflow policy mismatch: ${path}`) + } + for (const path of fields.keys()) + if (!expected.has(path)) + issues.push(`Unapproved release workflow field: ${path}`) return issues } @@ -48,6 +145,5 @@ export async function verifyWorkflows() { process.stdout.write('Workflow contracts verified.\n') } -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await verifyWorkflows() -} diff --git a/scripts/verify-workflows.test.mjs b/scripts/verify-workflows.test.mjs index 732f529..134a3dc 100644 --- a/scripts/verify-workflows.test.mjs +++ b/scripts/verify-workflows.test.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { test } from 'node:test' - import { validateReleaseWorkflow } from './verify-workflows.mjs' test('CI and browser workflows enforce the quality gates', async () => { @@ -9,38 +8,148 @@ test('CI and browser workflows enforce the quality gates', async () => { const browser = await readFile('.github/workflows/browser.yml', 'utf8') assert.match(ci, /npm ci/) assert.match(ci, /npm run check/) - for (const engine of ['chromium', 'firefox', 'webkit']) { + for (const engine of ['chromium', 'firefox', 'webkit']) assert.match(browser, new RegExp(engine)) - } }) -test('release workflow publishes only from a published GitHub release', async () => { - const release = await readFile('.github/workflows/release.yml', 'utf8') - assert.deepEqual(validateReleaseWorkflow(release), []) +test('release workflow allows staging only from current main through full gates', async () => { + assert.deepEqual( + validateReleaseWorkflow( + await readFile('.github/workflows/release.yml', 'utf8'), + ), + [], + ) +}) + +test('workflow requires consumer checks on the exact retained tarball before upload', async () => { + const source = await readFile('.github/workflows/release.yml', 'utf8') + const withoutSmoke = source.replace( + ' - run: ANCHORED_LAYER_PACKAGE_TARBALL=./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz npm run test:package\n', + '', + ) + assert.ok(validateReleaseWorkflow(withoutSmoke).length > 0) }) -test('release workflow rejects a manually publishable dispatch', () => { - const unsafeWorkflow = ` -on: - workflow_dispatch: - inputs: - publish: - type: boolean -permissions: - id-token: write -steps: - - if: inputs.publish - run: npm publish --provenance --access public --tag alpha -` +test('workflow contract rejects weakened staging policy and unsafe executable additions', async () => { + const source = await readFile('.github/workflows/release.yml', 'utf8') + const mutations = [ + [ + 'release trigger', + source.replace( + ' workflow_dispatch:', + ' release:\n types: [published]', + ), + ], + [ + 'wrong dispatch ref', + source.replace( + "github.ref == 'refs/heads/main'", + "github.ref == 'refs/heads/feature'", + ), + ], + [ + 'unprotected environment', + source.replace('environment: npm', 'environment: preview'), + ], + ['missing OIDC', source.replace('id-token: write', 'id-token: read')], + [ + 'unsafe concurrency', + source.replace('cancel-in-progress: false', 'cancel-in-progress: true'), + ], + [ + 'conditional gates', + source.replace( + ' - run: npm run check', + ' - run: npm run check\n if: false', + ), + ], + [ + 'skipped browser engine', + source.replace('chromium firefox webkit', 'chromium'), + ], + [ + 'missing website browser gate', + source.replace(' - run: npm run test:website:e2e\n', ''), + ], + [ + 'missing preparation guard', + source.replace( + ' - run: node scripts/verify-release.mjs --verify-current-main\n', + '', + ), + ], + [ + 'missing final guard', + source.replace( + / {6}- run: node scripts\/verify-release.mjs --verify-current-main\n(?= {6}- run: npm stage)/, + '', + ), + ], + [ + 'no uniqueness lookup', + source.replaceAll('--ensure-unpublished', '--dry-run'), + ], + ['unverified artifact', source.replace('--verify-retained', '--dry-run')], + [ + 'wrong stage artifact', + source.replace( + './artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz', + '.', + ), + ], + ['wrong channel', source.replace('--tag latest', '--tag alpha')], + ['missing provenance', source.replace('--provenance ', '')], + [ + 'unsafe extra stage flag', + source.replace('--tag latest', '--tag latest --provenance=false'), + ], + ['direct publish', source + ' - run: npm publish --access public\n'], + [ + 'missing artifact upload', + source.replace( + 'actions/upload-artifact@v7', + 'actions/download-artifact@v7', + ), + ], + [ + 'ignored missing artifact', + source.replace('if-no-files-found: error', 'if-no-files-found: ignore'), + ], + [ + 'overridden npm config', + source.replace( + 'NPM_CONFIG_USERCONFIG: /dev/null', + 'NPM_CONFIG_USERCONFIG: /tmp/credentials', + ), + ], + ['unknown shell command', source + ' - run: echo unverified\n'], + [ + 'credential override', + source.replace( + ' environment: npm', + ' environment: npm\n env:\n NODE_AUTH_TOKEN: unsafe', + ), + ], + [ + 'alternate working directory', + source.replace( + ' - run: npm stage', + ' - working-directory: /tmp\n run: npm stage', + ), + ], + ] + for (const [label, mutated] of mutations) { + assert.notEqual(mutated, source, `mutation must apply: ${label}`) + assert.ok(validateReleaseWorkflow(mutated).length > 0, label) + } +}) - assert.deepEqual(validateReleaseWorkflow(unsafeWorkflow), [ - 'release.published trigger is required', - 'manual dispatch must not accept a publish input', - 'publish step must be restricted to the release event', - 'release tag must be checked out explicitly', - 'duplicate npm versions must be rejected', - 'full browser matrix must run before publishing', - 'per-tag publish concurrency is required', - 'release dependency caching must be disabled', - ]) +test('workflow contract rejects duplicate keys and YAML aliases', () => { + assert.ok( + validateReleaseWorkflow('on:\n workflow_dispatch:\non:\n release:\n') + .length > 0, + ) + assert.ok( + validateReleaseWorkflow('jobs: &unsafe\n release: *unsafe\n').length > 0, + ) }) diff --git a/src/anchor.tsx b/src/anchor.tsx index 9752a0f..2896fc3 100644 --- a/src/anchor.tsx +++ b/src/anchor.tsx @@ -2,7 +2,9 @@ import { Children, cloneElement, forwardRef, + Fragment, isValidElement, + useMemo, type ReactElement, type Ref, } from 'react' @@ -18,30 +20,40 @@ export const Anchor = forwardRef( function Anchor({ asChild = false, children, ...anchorProps }, forwardedRef) { const { setAnchor } = useAnchoredLayerContext('AnchoredLayer.Anchor') - if (!asChild) { + let child: RefElement | undefined + if (asChild) { + try { + const onlyChild = Children.only(children) + if ( + !isValidElement<{ ref?: Ref }>(onlyChild) || + onlyChild.type === Fragment + ) + throw new Error() + child = onlyChild + } catch { + throw new Error( + 'AnchoredLayer.Anchor with asChild requires one React element', + ) + } + } + const childRef = child === undefined ? undefined : getElementRef(child) + const composedRef = useMemo( + () => composeRefs(childRef, forwardedRef, setAnchor), + [childRef, forwardedRef, setAnchor], + ) + + if (child === undefined) { return ( - + {children} ) } - let child: RefElement - try { - const onlyChild = Children.only(children) - if (!isValidElement<{ ref?: Ref }>(onlyChild)) - throw new Error() - child = onlyChild - } catch { - throw new Error( - 'AnchoredLayer.Anchor with asChild requires one React element', - ) - } - return cloneElement(child, { ...anchorProps, ...child.props, - ref: composeRefs(getElementRef(child), forwardedRef, setAnchor), + ref: composedRef, }) }, ) diff --git a/src/compose-refs.ts b/src/compose-refs.ts index 5ddda0e..71143b2 100644 --- a/src/compose-refs.ts +++ b/src/compose-refs.ts @@ -1,9 +1,11 @@ import type { Ref, RefCallback } from 'react' -function setRef(ref: Ref | undefined, value: T | null): void { +function setRef( + ref: Ref | undefined, + value: T | null, +): ReturnType> { if (typeof ref === 'function') { - ref(value) - return + return ref(value) } if (ref !== null && ref !== undefined) { @@ -15,6 +17,17 @@ export function composeRefs( ...refs: (Ref | undefined)[] ): RefCallback { return (value) => { - for (const ref of refs) setRef(ref, value) + const cleanups = refs.map((ref) => setRef(ref, value)) + // React 19 replaces the null callback with cleanup when any ref owns one. + // The remaining refs still need their normal detach notification. + if (cleanups.some((cleanup) => typeof cleanup === 'function')) { + return () => { + for (const [index, ref] of refs.entries()) { + const cleanup = cleanups[index] + if (typeof cleanup === 'function') cleanup() + else setRef(ref, null) + } + } + } } } diff --git a/src/content.tsx b/src/content.tsx index 3e91d4a..3b6685d 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -8,7 +8,6 @@ import { } from '@floating-ui/react-dom' import { forwardRef, - useLayoutEffect, useMemo, useState, type CSSProperties, @@ -20,10 +19,18 @@ import type { AnchoredLayerContentProps } from './contracts' import { useAnchoredLayerContext, usePortalBoundary } from './context' import { getPlacementData } from './geometry' import { Portal } from './portal' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' type CustomProperties = CSSProperties & Record<`--anchored-layer-${string}`, string> +function getDirection(element: HTMLElement): 'ltr' | 'rtl' { + return element.ownerDocument.defaultView?.getComputedStyle(element) + .direction === 'rtl' + ? 'rtl' + : 'ltr' +} + export const Content = forwardRef( function Content( { @@ -31,6 +38,7 @@ export const Content = forwardRef( children, collisionBoundary, collisionPadding = 8, + dir, matchAnchorWidth = false, offset = 4, placement = 'bottom-start', @@ -44,6 +52,14 @@ export const Content = forwardRef( 'AnchoredLayer.Content', ) const insidePortal = usePortalBoundary() + const positionKey = useMemo(() => Symbol(), [anchor, content]) + const [anchorDirection, setAnchorDirection] = useState<'ltr' | 'rtl'>('ltr') + const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr') + // Portals retain React context but lose inherited DOM direction. + useIsomorphicLayoutEffect(() => { + if (anchor !== null) setAnchorDirection(getDirection(anchor)) + if (content !== null) setDirection(getDirection(content)) + }) const middleware = useMemo(() => { const boundaryOptions = { ...(collisionBoundary === undefined @@ -77,54 +93,53 @@ export const Content = forwardRef( ) }, }), + // A completed result must belong to the current elements, not a previous anchor. + { + name: 'anchoredLayer', + options: positionKey, + fn: () => ({ data: { key: positionKey } }), + }, ] }, [ avoidCollisions, collisionBoundary, collisionPadding, - matchAnchorWidth, offset, + positionKey, ]) const { floatingStyles, isPositioned, + middlewareData, placement: finalPlacement, refs, + update, } = useFloating({ elements: { reference: anchor }, middleware, open, placement, strategy, - whileElementsMounted(reference, floating, update) { - return autoUpdate(reference, floating, update, { - ancestorResize: true, - ancestorScroll: true, - elementResize: true, - layoutShift: true, - }) - }, + whileElementsMounted: autoUpdate, }) - const [positionedPair, setPositionedPair] = useState<{ - anchor: HTMLElement - content: HTMLElement - } | null>(null) - - useLayoutEffect(() => { - if (isPositioned && anchor !== null && content !== null) { - setPositionedPair({ anchor, content }) - } else { - setPositionedPair(null) - } - }, [anchor, content, isPositioned]) + const resolvedKey = ( + middlewareData.anchoredLayer as { key?: symbol } | undefined + )?.key + useIsomorphicLayoutEffect(() => { + if (open && anchor !== null) update() + }, [anchor, direction, open, positionKey, resolvedKey, update]) + const composedRef = useMemo( + () => composeRefs(forwardedRef, refs.setFloating, setContent), + [forwardedRef, refs.setFloating, setContent], + ) if (!open || anchor === null) return null - const positioned = - isPositioned && - positionedPair?.anchor === anchor && - positionedPair.content === content - const { align, side, transformOrigin } = getPlacementData(finalPlacement) + const positioned = isPositioned && resolvedKey === positionKey + const { align, side, transformOrigin } = getPlacementData( + finalPlacement, + direction === 'rtl', + ) const mergedStyle: CustomProperties = { ...style, ...floatingStyles, @@ -137,12 +152,13 @@ export const Content = forwardRef( const layer = (
{children} diff --git a/src/geometry.ts b/src/geometry.ts index a3efe2f..b6d89e9 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -8,6 +8,7 @@ export interface PlacementData { export function getPlacementData( placement: AnchoredLayerPlacement, + rtl = false, ): PlacementData { const [side, explicitAlign] = placement.split('-') as [ PlacementData['side'], @@ -24,7 +25,7 @@ export function getPlacementData( align === 'center' ? 'center' : side === 'top' || side === 'bottom' - ? align === 'start' + ? (align === 'start') !== rtl ? 'left' : 'right' : align === 'start' diff --git a/test/browser/geometry.spec.ts b/test/browser/geometry.spec.ts index 39b4b99..0ecb2a6 100644 --- a/test/browser/geometry.spec.ts +++ b/test/browser/geometry.spec.ts @@ -81,4 +81,72 @@ test('start alignment respects RTL', async ({ page }) => { } }) expect(Math.abs(edges.anchor - edges.layer)).toBeLessThanOrEqual(1) + await expect(page.getByTestId('layer')).toHaveCSS('direction', 'rtl') + expect( + await page + .getByTestId('layer') + .evaluate((node) => + node.style.getPropertyValue('--anchored-layer-transform-origin'), + ), + ).toBe('right top') + await page.getByRole('button', { name: 'Toggle direction' }).click() + await expect(page.getByTestId('layer')).toHaveCSS('direction', 'ltr') + await expect + .poll(() => + page.evaluate(() => { + const anchor = document.querySelector('[data-testid="anchor"]') + const layer = document.querySelector('[data-testid="layer"]') + if (!(anchor instanceof HTMLElement) || !(layer instanceof HTMLElement)) + throw new Error('Missing layer') + return Math.abs( + anchor.getBoundingClientRect().left - + layer.getBoundingClientRect().left, + ) + }), + ) + .toBeLessThanOrEqual(1) + expect( + await page + .getByTestId('layer') + .evaluate((node) => + node.style.getPropertyValue('--anchored-layer-transform-origin'), + ), + ).toBe('left top') +}) + +test('origin follows automatic and CSS direction on the content', async ({ + page, +}) => { + await page.goto('/?fixture=direction') + const layer = page.getByTestId('layer') + const origin = () => + layer.evaluate((node) => + node.style.getPropertyValue('--anchored-layer-transform-origin'), + ) + for (const mode of ['auto', 'class']) { + if (mode === 'class') + await page.getByRole('button', { name: 'Use CSS direction' }).click() + await expect(layer).toHaveCSS('direction', 'rtl') + await expect.poll(origin).toBe('right top') + await expect + .poll(() => + page.evaluate(() => { + const anchor = document.querySelector('[data-testid="anchor"]') + const content = document.querySelector('[data-testid="layer"]') + if ( + !(anchor instanceof HTMLElement) || + !(content instanceof HTMLElement) + ) + throw new Error('Missing layer') + return Math.abs( + anchor.getBoundingClientRect().right - + content.getBoundingClientRect().right, + ) + }), + ) + .toBeLessThanOrEqual(1) + } + await page.getByRole('button', { name: 'Use LTR direction' }).click() + await expect(layer).toHaveCSS('direction', 'ltr') + await expect.poll(origin).toBe('left top') }) diff --git a/test/browser/main.tsx b/test/browser/main.tsx index 972dad0..fcb80e0 100644 --- a/test/browser/main.tsx +++ b/test/browser/main.tsx @@ -14,13 +14,35 @@ function Layer({ children = 'Result' }: { children?: React.ReactNode }) { } function BasicFixture({ rtl = false }: { rtl?: boolean }) { + const [rightToLeft, setRightToLeft] = useState(rtl) return ( -
+
+ {rtl ? ( + + ) : null} - + {rtl ? ( + + Result + + ) : ( + + )}
) @@ -55,6 +77,43 @@ function ScrollFixture() { ) } +function DirectionFixture() { + const [mode, setMode] = useState<'auto' | 'class' | 'ltr'>('auto') + return ( +
+ + + + + + + + שלום + + +
+ ) +} + function CollisionFixture() { const [count, setCount] = useState(1) return ( @@ -124,6 +183,8 @@ const content = ) : fixture === 'rtl' ? ( + ) : fixture === 'direction' ? ( + ) : fixture === 'lifecycle' ? ( ) : ( diff --git a/test/browser/styles.css b/test/browser/styles.css index 23c6500..2124abe 100644 --- a/test/browser/styles.css +++ b/test/browser/styles.css @@ -87,3 +87,6 @@ input { min-height: 32px; padding: 5px; } +.rtl-content { + direction: rtl; +} diff --git a/test/package/assert-consumer.cjs.fixture b/test/package/assert-consumer.cjs.fixture new file mode 100644 index 0000000..1bf1ac3 --- /dev/null +++ b/test/package/assert-consumer.cjs.fixture @@ -0,0 +1,53 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') + +module.exports = function assertConsumer(React, renderToString, api, resolve) { + assert.equal(typeof window, 'undefined') + assert.equal(typeof document, 'undefined') + assert.deepEqual(Object.keys(api).sort(), [ + 'Anchor', + 'AnchoredLayer', + 'Content', + 'Portal', + 'Provider', + 'Root', + ]) + assert.ok(Object.isFrozen(api.AnchoredLayer)) + for (const name of ['Anchor', 'Content', 'Portal', 'Provider', 'Root']) { + assert.equal(api.AnchoredLayer[name], api[name]) + } + + for (const components of [api, api.AnchoredLayer]) { + const markup = renderToString( + React.createElement( + components.Provider, + null, + React.createElement( + components.Root, + { open: true }, + React.createElement( + components.Anchor, + { asChild: true }, + React.createElement('button', { type: 'button' }, 'Server anchor'), + ), + React.createElement(components.Content, null, 'Floating content'), + ), + ), + ) + assert.equal(markup, '') + assert.equal( + renderToString( + React.createElement(components.Portal, null, 'Portal content'), + ), + '', + ) + } + + for (const stylesheet of ['core.css', 'theme.css', 'styles.css']) { + const stylesheetPath = resolve( + `@nipe-solutions/react-anchored-layer/${stylesheet}`, + ) + assert.ok(readFileSync(stylesheetPath, 'utf8').trim().length > 0) + } + console.log('consumer contracts verified') +} diff --git a/test/package/consumer-types.cts.fixture b/test/package/consumer-types.cts.fixture new file mode 100644 index 0000000..c78319e --- /dev/null +++ b/test/package/consumer-types.cts.fixture @@ -0,0 +1,21 @@ +import React = require('react') +import Layer = require('@nipe-solutions/react-anchored-layer') + +const anchorRef = React.createRef() +const contentRef = React.createRef() +const contentProps: Layer.AnchoredLayerContentProps = { + placement: 'top-end', + strategy: 'absolute', + offset: 8, +} + +export const usage = React.createElement( + Layer.AnchoredLayer.Root, + { open: true }, + React.createElement(Layer.Anchor, { ref: anchorRef }, 'Anchor'), + React.createElement( + Layer.AnchoredLayer.Content, + { ...contentProps, ref: contentRef }, + 'Content', + ), +) diff --git a/test/package/consumer-types.tsx.fixture b/test/package/consumer-types.tsx.fixture new file mode 100644 index 0000000..737367d --- /dev/null +++ b/test/package/consumer-types.tsx.fixture @@ -0,0 +1,86 @@ +import { createRef, type RefCallback } from 'react' +import { + Anchor, + AnchoredLayer, + Content, + Portal, + Provider, + Root, + type AnchoredLayerAnchorProps, + type AnchoredLayerContentProps, + type AnchoredLayerOffset, + type AnchoredLayerOffsetOptions, + type AnchoredLayerPlacement, + type AnchoredLayerPortalProps, + type AnchoredLayerProviderProps, + type AnchoredLayerRootProps, + type AnchoredLayerStrategy, +} from '@nipe-solutions/react-anchored-layer' + +const anchorRef = createRef() +const contentRef = createRef() +const anchorCallback: RefCallback = (element) => { + element?.focus() +} +const contentCallback: RefCallback = (element) => { + element?.style.setProperty('color', 'red') +} +const cleanupRef: RefCallback = (element) => { + element?.setAttribute('data-attached', '') + return () => element?.removeAttribute('data-attached') +} +const placement: AnchoredLayerPlacement = 'bottom-start' +const strategy: AnchoredLayerStrategy = 'fixed' +const offsetOptions: AnchoredLayerOffsetOptions = { crossAxis: 2, mainAxis: 6 } +const offset: AnchoredLayerOffset = offsetOptions +const anchorProps: AnchoredLayerAnchorProps = { asChild: true, title: 'Anchor' } +const contentProps: AnchoredLayerContentProps = { + placement, + strategy, + offset, + collisionPadding: 8, + matchAnchorWidth: true, + role: 'dialog', + onClick: (event) => event.currentTarget.focus(), +} +const rootProps: AnchoredLayerRootProps = { + open: true, + onOpenChange: (open: boolean) => void open, +} +const providerProps: AnchoredLayerProviderProps = { portalRoot: null } +const portalProps: AnchoredLayerPortalProps = { container: null } + +export const namedUsage = ( + + + + + + + + Content + + + + +) + +export const namespaceUsage = ( + + + Anchor + + Content + + Cleanup ref + + +) + +// @ts-expect-error An anchor must use an HTML element ref. +export const invalidAnchorRef = ()} /> +const buttonRef = createRef() +// @ts-expect-error Content forwards a div ref, not a button ref. +export const invalidContentRef = +// @ts-expect-error A placement outside the public union is invalid. +export const invalidPlacement: AnchoredLayerPlacement = 'center' diff --git a/test/package/consumer.cjs.fixture b/test/package/consumer.cjs.fixture new file mode 100644 index 0000000..68c5c5f --- /dev/null +++ b/test/package/consumer.cjs.fixture @@ -0,0 +1,6 @@ +const React = require('react') +const { renderToString } = require('react-dom/server') +const api = require('@nipe-solutions/react-anchored-layer') +const assertConsumer = require('./assert-consumer.cjs') + +assertConsumer(React, renderToString, api, require.resolve) diff --git a/test/package/consumer.mjs.fixture b/test/package/consumer.mjs.fixture new file mode 100644 index 0000000..2e36674 --- /dev/null +++ b/test/package/consumer.mjs.fixture @@ -0,0 +1,11 @@ +import React from 'react' +import { renderToString } from 'react-dom/server' +import * as api from '@nipe-solutions/react-anchored-layer' +import assertConsumer from './assert-consumer.cjs' + +assertConsumer( + React, + renderToString, + api, + (specifier) => new URL(import.meta.resolve(specifier)), +) diff --git a/test/package/verify-package.test.mjs b/test/package/verify-package.test.mjs index 21f2b16..0b8101e 100644 --- a/test/package/verify-package.test.mjs +++ b/test/package/verify-package.test.mjs @@ -1,95 +1,237 @@ import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync } from 'node:fs' +import { execFileSync, spawnSync } from 'node:child_process' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { test } from 'node:test' const projectRoot = resolve(import.meta.dirname, '../..') +const packageManifest = JSON.parse( + readFileSync(join(projectRoot, 'package.json'), 'utf8'), +) +const consumerFiles = [ + 'consumer.mjs', + 'consumer.cjs', + 'assert-consumer.cjs', + 'consumer-types.tsx', + 'consumer-types.cts', +] +const consumers = [ + { + name: 'React 18.3', + react: '18.3.1', + reactTypes: '18.3.31', + reactDomTypes: '18.3.7', + }, + { + name: 'React 19', + react: '19.2.8', + reactTypes: '19.2.18', + reactDomTypes: '19.2.7', + }, +] -test('packed package supports ESM, CommonJS, SSR, types, and CSS exports', () => { - const packOutput = JSON.parse( - execFileSync('npm', ['pack', '--json'], { - cwd: projectRoot, - encoding: 'utf8', - }), - )[0] - const files = packOutput.files.map(({ path }) => path).sort() +function runConsumer(fixture, filename) { + const result = spawnSync(process.execPath, [filename], { + cwd: fixture, + encoding: 'utf8', + }) + assert.ifError(result.error) + assert.equal(result.status, 0, result.stderr || result.stdout) + assert.equal(result.stderr, '', `${filename} emitted a server warning`) + assert.equal(result.stdout.trim(), 'consumer contracts verified') +} - assert.ok( - packOutput.size <= 15_000, - `tarball is ${String(packOutput.size)} bytes`, +function compileConsumer(fixture, moduleResolution) { + const config = { + compilerOptions: { + strict: true, + exactOptionalPropertyTypes: true, + skipLibCheck: false, + noEmit: true, + jsx: 'react-jsx', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + module: moduleResolution === 'NodeNext' ? 'NodeNext' : 'ESNext', + moduleResolution, + types: ['react', 'react-dom'], + }, + files: + moduleResolution === 'NodeNext' + ? ['consumer-types.tsx', 'consumer-types.cts'] + : ['consumer-types.tsx'], + } + writeFileSync(join(fixture, 'tsconfig.json'), JSON.stringify(config)) + return spawnSync( + process.execPath, + ['node_modules/typescript/bin/tsc', '--project', 'tsconfig.json'], + { cwd: fixture, encoding: 'utf8' }, ) +} - assert.deepEqual(files, [ - 'CHANGELOG.md', - 'LICENSE', - 'README.md', - 'dist/core.css', - 'dist/index.cjs', - 'dist/index.d.ts', - 'dist/index.js', - 'dist/styles.css', - 'dist/theme.css', - 'package.json', - ]) +test('packed package preserves the published consumer contract', async (t) => { + const scratch = mkdtempSync(join(tmpdir(), 'anchored-layer-package-')) + try { + const suppliedTarball = process.env.ANCHORED_LAYER_PACKAGE_TARBALL + const tarball = suppliedTarball + ? resolve(suppliedTarball) + : join( + scratch, + JSON.parse( + execFileSync( + 'npm', + ['pack', '--json', '--pack-destination', scratch], + { + cwd: projectRoot, + encoding: 'utf8', + env: { ...process.env, NPM_CONFIG_USERCONFIG: '/dev/null' }, + }, + ), + )[0].filename, + ) - const fixture = mkdtempSync(join(tmpdir(), 'anchored-layer-package-')) - const tarball = join(projectRoot, packOutput.filename) - execFileSync( - 'npm', - [ - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - tarball, - 'react@19.2.8', - 'react-dom@19.2.8', - 'typescript@6.0.3', - '@types/react@19.2.18', - '@types/react-dom@19.2.7', - ], - { cwd: fixture, stdio: 'pipe' }, - ) + await t.test( + 'archive manifest and ten public files satisfy the tarball budget', + () => { + const size = statSync(tarball).size + assert.ok(size <= 15_000, `tarball is ${String(size)} bytes`) + const archiveFiles = execFileSync('tar', ['-tzf', tarball], { + encoding: 'utf8', + }) + .trim() + .split(/\r?\n/) + .sort() + assert.deepEqual(archiveFiles, [ + 'package/CHANGELOG.md', + 'package/LICENSE', + 'package/README.md', + 'package/dist/core.css', + 'package/dist/index.cjs', + 'package/dist/index.d.ts', + 'package/dist/index.js', + 'package/dist/styles.css', + 'package/dist/theme.css', + 'package/package.json', + ]) + const archivedManifest = JSON.parse( + execFileSync('tar', ['-xOzf', tarball, 'package/package.json'], { + encoding: 'utf8', + }), + ) + assert.equal(archivedManifest.name, packageManifest.name) + assert.equal(archivedManifest.version, packageManifest.version) + }, + ) - const packageRoot = join( - fixture, - 'node_modules/@nipe-solutions/react-anchored-layer', - ) - const esm = execFileSync( - 'node', - [ - '--input-type=module', - '--eval', - "import('@nipe-solutions/react-anchored-layer').then(m => console.log(Object.keys(m).sort().join(',')))", - ], - { cwd: fixture, encoding: 'utf8' }, - ) - const cjs = execFileSync( - 'node', - [ - '--eval', - "console.log(Object.keys(require('@nipe-solutions/react-anchored-layer')).sort().join(','))", - ], - { cwd: fixture, encoding: 'utf8' }, - ) - const ssr = execFileSync( - 'node', - [ - '--input-type=module', - '--eval', - "import React from 'react'; import {renderToString} from 'react-dom/server'; import {AnchoredLayer as A} from '@nipe-solutions/react-anchored-layer'; console.log(renderToString(React.createElement(A.Root,null,React.createElement(A.Anchor,null,'anchor'))))", - ], - { cwd: fixture, encoding: 'utf8' }, - ) + for (const consumer of consumers) { + await t.test( + `${consumer.name} installed consumer`, + async (consumerTest) => { + const fixture = join(scratch, `react-${consumer.react}`) + mkdirSync(fixture) + writeFileSync( + join(fixture, 'package.json'), + JSON.stringify({ private: true, type: 'module' }), + ) + execFileSync( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + tarball, + `react@${consumer.react}`, + `react-dom@${consumer.react}`, + 'typescript@6.0.3', + `@types/react@${consumer.reactTypes}`, + `@types/react-dom@${consumer.reactDomTypes}`, + ], + { + cwd: fixture, + stdio: 'pipe', + env: { ...process.env, NPM_CONFIG_USERCONFIG: '/dev/null' }, + }, + ) + for (const filename of consumerFiles) { + copyFileSync( + join(import.meta.dirname, `${filename}.fixture`), + join(fixture, filename), + ) + } - assert.equal(esm.trim(), 'Anchor,AnchoredLayer,Content,Portal,Provider,Root') - assert.equal(cjs.trim(), esm.trim()) - assert.match(ssr, /anchor/) - for (const stylesheet of ['core.css', 'theme.css', 'styles.css']) { - assert.ok( - readFileSync(join(packageRoot, 'dist', stylesheet), 'utf8').length > 0, - ) + await consumerTest.test( + 'ESM exports, open SSR, and CSS resolution', + () => { + runConsumer(fixture, 'consumer.mjs') + }, + ) + await consumerTest.test( + 'CommonJS exports, open SSR, and CSS resolution', + () => { + runConsumer(fixture, 'consumer.cjs') + }, + ) + for (const resolution of ['NodeNext', 'Bundler']) { + await consumerTest.test( + `${resolution} declarations and DOM refs compile`, + () => { + const result = compileConsumer(fixture, resolution) + assert.ifError(result.error) + assert.equal(result.status, 0, result.stdout || result.stderr) + }, + ) + } + + await consumerTest.test( + 'type validation rejects an incompatible published anchor ref', + () => { + const declarations = join( + fixture, + 'node_modules/@nipe-solutions/react-anchored-layer/dist/index.d.ts', + ) + const original = readFileSync(declarations, 'utf8') + const incompatible = original.replaceAll( + 'RefAttributes', + 'RefAttributes', + ) + assert.notEqual( + incompatible, + original, + 'anchor ref mutation was not applied', + ) + try { + writeFileSync(declarations, incompatible) + const result = compileConsumer(fixture, 'NodeNext') + assert.ifError(result.error) + assert.notEqual( + result.status, + 0, + 'incompatible ref declarations compiled', + ) + assert.match( + result.stdout, + /consumer-types\.(tsx|cts)\(\d+,\d+\): error TS(2322|2769):/, + ) + } finally { + writeFileSync(declarations, original) + } + }, + ) + }, + ) + } + } finally { + // Only this test-owned directory is removed; a supplied release tarball is retained. + rmSync(scratch, { recursive: true, force: true }) } }) diff --git a/test/unit/anchor.test.tsx b/test/unit/anchor.test.tsx index 2a5ab5f..bc59f6a 100644 --- a/test/unit/anchor.test.tsx +++ b/test/unit/anchor.test.tsx @@ -1,4 +1,4 @@ -import { createRef, forwardRef, useState } from 'react' +import { createRef, forwardRef, Fragment, useState } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' @@ -96,4 +96,65 @@ describe('Anchor', () => { ), ).toThrow('AnchoredLayer.Anchor with asChild requires one React element') }) + + it('keeps a child ref attached across ordinary parent renders', () => { + const attachments: (HTMLElement | null)[] = [] + const childRef = (node: HTMLElement | null) => { + attachments.push(node) + } + const fixture = (label: string) => ( + + + + + + ) + const { rerender, unmount } = render(fixture('Before')) + const node = screen.getByRole('button') + attachments.length = 0 + rerender(fixture('After')) + expect(screen.getByRole('button')).toBe(node) + expect(attachments).toEqual([]) + unmount() + expect(attachments).toEqual([null]) + }) + + it('runs React 19 child ref cleanup when the anchor unmounts', () => { + const attached: HTMLElement[] = [] + const released: HTMLElement[] = [] + const childRef = (node: HTMLElement | null) => { + if (node !== null) { + attached.push(node) + return () => { + released.push(node) + } + } + } + const { unmount } = render( + + + + + , + ) + const node = screen.getByRole('button') + expect(attached).toEqual([node]) + expect(released).toEqual([]) + unmount() + expect(released).toEqual([node]) + }) + + it('rejects a fragment that cannot provide an anchor element', () => { + expect(() => + render( + + + + + + + , + ), + ).toThrow('AnchoredLayer.Anchor with asChild requires one React element') + }) }) diff --git a/test/unit/compose-refs.test.ts b/test/unit/compose-refs.test.ts new file mode 100644 index 0000000..9cec86d --- /dev/null +++ b/test/unit/compose-refs.test.ts @@ -0,0 +1,34 @@ +import { createRef } from 'react' +import { describe, expect, it } from 'vitest' + +import { composeRefs } from '../../src/compose-refs' + +describe('composed ref cleanup', () => { + it('releases callback resources and clears object and legacy callback refs', () => { + const node = document.createElement('button') + const objectRef = createRef() + const legacyValues: (HTMLButtonElement | null)[] = [] + const released: HTMLButtonElement[] = [] + const ref = composeRefs( + (value) => + value === null + ? undefined + : () => { + released.push(value) + }, + objectRef, + (value) => { + legacyValues.push(value) + }, + ) + const cleanup = ref(node) + expect(objectRef.current).toBe(node) + expect(legacyValues).toEqual([node]) + expect(cleanup).toBeTypeOf('function') + if (typeof cleanup !== 'function') throw new Error('Missing ref cleanup') + cleanup() + expect(released).toEqual([node]) + expect(objectRef.current).toBeNull() + expect(legacyValues).toEqual([node, null]) + }) +}) diff --git a/test/unit/content-lifecycle.test.tsx b/test/unit/content-lifecycle.test.tsx new file mode 100644 index 0000000..2979662 --- /dev/null +++ b/test/unit/content-lifecycle.test.tsx @@ -0,0 +1,196 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import { platform } from '@floating-ui/react-dom' +import { flushSync } from 'react-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { AnchoredLayer as A } from '../../src' + +describe('content ref lifecycle', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() { + /* JSDOM has no layout engine. */ + } + unobserve() { + /* JSDOM has no layout engine. */ + } + disconnect() { + /* JSDOM has no layout engine. */ + } + }, + ) + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() { + /* Observer notifications require a browser layout engine. */ + } + disconnect() { + /* Observer notifications require a browser layout engine. */ + } + }, + ) + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + it('retains the content ref through rerenders and releases it on close', () => { + const attachments: HTMLElement[] = [] + const releases: HTMLElement[] = [] + const contentRef = (node: HTMLDivElement | null) => { + if (node !== null) { + attachments.push(node) + return () => { + releases.push(node) + } + } + } + const fixture = (open: boolean, text: string) => ( + + Anchor + + {text} + + + ) + const { rerender } = render(fixture(true, 'Before')) + const node = screen.getByTestId('layer') + expect(attachments).toEqual([node]) + rerender(fixture(true, 'After')) + expect(attachments).toEqual([node]) + expect(releases).toEqual([]) + rerender(fixture(false, 'After')) + expect(screen.queryByTestId('layer')).not.toBeInTheDocument() + expect(releases).toEqual([node]) + }) + + it('hides a replacement anchor until its own position has resolved', async () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + return new DOMRect( + this.dataset.testid === 'after' ? 500 : 100, + 60, + 100, + 20, + ) + }, + ) + const fixture = (anchorKey: string) => ( + + + Anchor + + + Layer + + + ) + const { rerender } = render(fixture('before')) + await waitFor(() => { + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'true', + ) + }) + const oldTransform = screen.getByTestId('layer').style.transform + + act(() => { + flushSync(() => { + rerender(fixture('after')) + }) + }) + + // The real positioning hook still has its old result before its promise settles. + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'false', + ) + expect(screen.getByTestId('layer')).toHaveStyle({ visibility: 'hidden' }) + await waitFor(() => { + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'true', + ) + expect(screen.getByTestId('layer').style.transform).not.toBe(oldTransform) + }) + }) + + it('recovers when a previous anchor calculation finishes after its replacement', async () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + return new DOMRect( + this.dataset.testid === 'after' ? 500 : 100, + 60, + 100, + 20, + ) + }, + ) + const fixture = (anchorKey: string) => ( + + + Anchor + + + Layer + + + ) + const { rerender } = render(fixture('before')) + await waitFor(() => { + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'true', + ) + }) + + let releaseOldCalculation: (() => void) | undefined + let delayOldCalculation = true + const getElementRects = platform.getElementRects.bind(platform) + vi.spyOn(platform, 'getElementRects').mockImplementation( + async (options) => { + const rects = await getElementRects(options) + if ( + delayOldCalculation && + options.reference instanceof HTMLElement && + options.reference.dataset.testid === 'before' + ) { + delayOldCalculation = false + await new Promise((resolve) => { + releaseOldCalculation = resolve + }) + } + return rects + }, + ) + window.dispatchEvent(new Event('resize')) + await waitFor(() => { + expect(releaseOldCalculation).toBeTypeOf('function') + }) + + rerender(fixture('after')) + await waitFor(() => { + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'true', + ) + }) + const replacementTransform = screen.getByTestId('layer').style.transform + await act(async () => { + releaseOldCalculation?.() + await Promise.resolve() + }) + await waitFor(() => { + expect(screen.getByTestId('layer')).toHaveAttribute( + 'data-positioned', + 'true', + ) + expect(screen.getByTestId('layer').style.transform).toBe( + replacementTransform, + ) + }) + }) +}) diff --git a/test/unit/content.test.tsx b/test/unit/content.test.tsx index 0bde05b..e3f1c36 100644 --- a/test/unit/content.test.tsx +++ b/test/unit/content.test.tsx @@ -28,11 +28,17 @@ vi.mock('@floating-ui/react-dom', () => ({ context: {}, floatingStyles: { left: 12, position: 'absolute', top: 34 }, isPositioned: floatingState.isPositioned, + middlewareData: { + anchoredLayer: { + key: (options.middleware.at(-1) as { options: symbol }).options, + }, + }, placement: floatingState.placement, refs: { setFloating: vi.fn(), setReference: vi.fn(), }, + update: vi.fn(), } }), })) @@ -112,7 +118,7 @@ describe('Content', () => { expect( floatingState.middleware.map((entry) => (entry as { name: string }).name), - ).toEqual(['offset', 'flip', 'shift', 'size']) + ).toEqual(['offset', 'flip', 'shift', 'size', 'anchoredLayer']) expect((floatingState.middleware[0] as { options: unknown }).options).toBe( 6, ) diff --git a/test/unit/geometry.test.ts b/test/unit/geometry.test.ts index 4b6bc9d..2583445 100644 --- a/test/unit/geometry.test.ts +++ b/test/unit/geometry.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it } from 'vitest' import { getPlacementData } from '../../src/geometry' describe('getPlacementData', () => { + it.each([ + ['top-start', 'right bottom'], + ['top-end', 'left bottom'], + ['bottom-start', 'right top'], + ['bottom-end', 'left top'], + ['right-start', 'left top'], + ['left-end', 'right bottom'], + ] as const)('maps %s to the physical RTL origin', (placement, origin) => { + expect(getPlacementData(placement, true).transformOrigin).toBe(origin) + }) it.each([ ['top-start', 'top', 'start', 'left bottom'], ['top', 'top', 'center', 'center bottom'], diff --git a/website/src/App.tsx b/website/src/App.tsx index 083e0c6..708d624 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -45,7 +45,7 @@ export function App() {
-

0.1 alpha · React 18.3 and 19

+

1.0.0 stable · React 18.3 and 19

Floating content that stays attached.

A small React primitive for rendering content through a portal @@ -53,12 +53,14 @@ export function App() { content changes, and layout shifts.

- npm install @nipe-solutions/react-anchored-layer + + npm install --save-exact @nipe-solutions/react-anchored-layer +
absolute by default or{' '} fixed for fixed-position application contexts.

+

+ Start and end follow the anchor’s text direction, including when + content portals to the body. +

+

+ Render one Anchor and one Content per Root. An{' '} + asChild child must forward its ref to an + HTMLElement; fragments are not anchors. React 19 ref cleanup is + preserved. +

+

+ See the{' '} + + complete API reference + {' '} + for every prop and default. +