Don't break overflow when multiple dialogs are open at the same time (#2215)

* Fix overflow when swapping dialogs that use transition

* Refactor

* refactor

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Inline shim for ESM support

Until the official package adds an ESM version with a wildcard import we can’t use it. This version was copied from Remix Router

* Add dialog shadow root examples

* Fix SSR error

* Add repro for iOS scrolling issue

* Try to fix vercel build

idk what’s wrong here

* Update repro

A transition is required to delay closing enough to demonstrate the bug

* Port global dialog state to Vue

* Add dialog test to Vue

* wip

* wip

* Workaround bug

This shouldn’t happen at all and we need to find the source of the bug but this should “fix” things for the time being

* wip

* Rebuild overflow locking with simpler API

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update deps

* wip

* simplify

* Port to Vue

* wip

* wip

* Tweak tests

* Update changelog

* Ensure meta callbacks are cleaned up

* cleanup

* wip
This commit is contained in:
Jordan Pittman
2023-02-01 16:08:34 -05:00
committed by GitHub
parent e95f664a36
commit 2f99644ed7
30 changed files with 1305 additions and 238 deletions
+3
View File
@@ -8,6 +8,9 @@ module.exports = function createJestConfig(root, options) {
'^.+\\.(t|j)sx?$': '@swc/jest',
...transform,
},
globals: {
__DEV__: true,
},
},
rest
)
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fix SSR tab hydration when using Strict Mode in development ([#2231](https://github.com/tailwindlabs/headlessui/pull/2231))
- Don't break overflow when multiple dialogs are open at the same time ([#2215](https://github.com/tailwindlabs/headlessui/pull/2215))
## [1.7.8] - 2023-01-27
@@ -1,4 +1,4 @@
import React, { createElement, useRef, useState, Fragment, useEffect } from 'react'
import React, { createElement, useRef, useState, Fragment, useEffect, useCallback } from 'react'
import { render } from '@testing-library/react'
import { Dialog } from './dialog'
@@ -37,13 +37,13 @@ global.IntersectionObserver = class FakeIntersectionObserver {
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
})
})
})
return frames(1)
}
async function frames(count: number) {
for (let n = 0; n <= count; n++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
function TabSentinel(props: PropsOf<'button'>) {
@@ -323,6 +323,94 @@ describe('Rendering', () => {
expect(document.documentElement.style.overflow).toBe('hidden')
})
)
it(
'scroll locking should work when transitioning between dialogs',
suppressConsoleLogs(async () => {
// While we don't support multiple dialogs
// We at least want to work towards supporting it at some point
// The first step is just making sure that scroll locking works
// when there are multiple dialogs open at the same time
function Example() {
let [dialogs, setDialogs] = useState<string[]>([])
let toggle = useCallback(
(id: string, state: 'open' | 'close') => {
if (state === 'open' && !dialogs.includes(id)) {
setDialogs([id])
} else if (state === 'close' && dialogs.includes(id)) {
setDialogs(dialogs.filter((x) => x !== id))
}
},
[dialogs]
)
return (
<>
<DialogWrapper id="d1" dialogs={dialogs} toggle={toggle} />
<DialogWrapper id="d2" dialogs={dialogs} toggle={toggle} />
<DialogWrapper id="d3" dialogs={dialogs} toggle={toggle} />
</>
)
}
function DialogWrapper({
id,
dialogs,
toggle,
}: {
id: string
dialogs: string[]
toggle: (id: string, state: 'open' | 'close') => void
}) {
return (
<>
<button id={`open_${id}`} onClick={() => toggle(id, 'open')}>
Open {id}
</button>
<Transition as={Fragment} show={dialogs.includes(id)}>
<Dialog onClose={() => toggle(id, 'close')}>
<button id={`close_${id}`} onClick={() => toggle(id, 'close')}>
Close {id}
</button>
</Dialog>
</Transition>
</>
)
}
render(<Example />)
// No overflow yet
expect(document.documentElement.style.overflow).toBe('')
let open1 = () => document.getElementById('open_d1')
let open2 = () => document.getElementById('open_d2')
let open3 = () => document.getElementById('open_d3')
let close3 = () => document.getElementById('close_d3')
// Open the dialog & expect overflow
await click(open1())
await frames(2)
expect(document.documentElement.style.overflow).toBe('hidden')
// Open the dialog & expect overflow
await click(open2())
await frames(2)
expect(document.documentElement.style.overflow).toBe('hidden')
// Open the dialog & expect overflow
await click(open3())
await frames(2)
expect(document.documentElement.style.overflow).toBe('hidden')
// At this point only the last dialog should be open
// Close the dialog & dont expect overflow
await click(close3())
await frames(2)
expect(document.documentElement.style.overflow).toBe('')
})
)
})
describe('Dialog.Overlay', () => {
@@ -37,8 +37,7 @@ import { useOwnerDocument } from '../../hooks/use-owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useEvent } from '../../hooks/use-event'
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'
import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow'
enum DialogStates {
Open,
@@ -96,110 +95,9 @@ function useScrollLock(
enabled: boolean,
resolveAllowedContainers: () => HTMLElement[] = () => [document.body]
) {
useEffect(() => {
if (!enabled) return
if (!ownerDocument) return
let d = disposables()
let scrollPosition = window.pageYOffset
function style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return d.add(() => {
Object.assign(node.style, { [property]: previous })
})
}
let documentElement = ownerDocument.documentElement
let ownerWindow = ownerDocument.defaultView ?? window
let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
style(documentElement, 'overflow', 'hidden')
if (scrollbarWidthBefore > 0) {
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
}
if (isIOS()) {
style(ownerDocument.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
ownerDocument,
'click',
(e) => {
if (e.target instanceof HTMLElement) {
try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = ownerDocument.querySelector(hash)
if (el && !resolveAllowedContainers().some((container) => container.contains(el))) {
scrollToElement = el as HTMLElement
}
} catch (err) {}
}
},
true
)
d.addEventListener(
ownerDocument,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
if (
e.target instanceof HTMLElement &&
!resolveAllowedContainers().some((container) =>
container.contains(e.target as HTMLElement)
)
) {
e.preventDefault()
}
},
{ passive: false }
)
// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)
// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
})
}
return d.dispose
}, [ownerDocument, enabled])
useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({
containers: [...(meta.containers ?? []), resolveAllowedContainers],
}))
}
function stateReducer(state: StateDefinition, action: Actions) {
@@ -0,0 +1,25 @@
import { ScrollLockStep } from './overflow-store'
export function adjustScrollbarPadding(): ScrollLockStep {
let scrollbarWidthBefore: number
return {
before({ doc }) {
let documentElement = doc.documentElement
let ownerWindow = doc.defaultView ?? window
scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
},
after({ doc, d }) {
let documentElement = doc.documentElement
// Account for the change in scrollbar width
// NOTE: This is a bit of a hack, but it's the only way to do this
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
d.style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
},
}
}
@@ -0,0 +1,98 @@
import { isIOS } from '../../utils/platform'
import { ScrollLockStep } from './overflow-store'
interface ContainerMetadata {
containers: (() => HTMLElement[])[]
}
export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
if (!isIOS()) {
return {}
}
let scrollPosition: number
return {
before() {
scrollPosition = window.pageYOffset
},
after({ doc, d, meta }) {
function inAllowedContainer(el: HTMLElement) {
return meta.containers
.flatMap((resolve) => resolve())
.some((container) => container.contains(el))
}
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
doc,
'click',
(e) => {
if (!(e.target instanceof HTMLElement)) {
return
}
try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = doc.querySelector(hash)
if (el && !inAllowedContainer(el as HTMLElement)) {
scrollToElement = el as HTMLElement
}
} catch (err) {}
},
true
)
d.addEventListener(
doc,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) {
e.preventDefault()
}
},
{ passive: false }
)
// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)
// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
})
},
}
}
@@ -0,0 +1,118 @@
import { disposables, Disposables } from '../../utils/disposables'
import { createStore } from '../../utils/store'
import { adjustScrollbarPadding } from './adjust-scrollbar-padding'
import { handleIOSLocking } from './handle-ios-locking'
import { preventScroll } from './prevent-scroll'
interface DocEntry {
doc: Document
count: number
d: Disposables
meta: Set<MetaFn>
}
function buildMeta(fns: Iterable<MetaFn>) {
let tmp = {}
for (let fn of fns) {
Object.assign(tmp, fn(tmp))
}
return tmp
}
export type MetaFn = (meta: Record<string, any>) => Record<string, any>
export interface Context<MetaType extends Record<string, any> = any> {
doc: Document
d: Disposables
meta: MetaType
}
export interface ScrollLockStep<MetaType extends Record<string, any> = any> {
before?(ctx: Context<MetaType>): void
after?(ctx: Context<MetaType>): void
}
export let overflows = createStore(() => new Map<Document, DocEntry>(), {
PUSH(doc: Document, meta: MetaFn) {
let entry = this.get(doc) ?? {
doc,
count: 0,
d: disposables(),
meta: new Set(),
}
entry.count++
entry.meta.add(meta)
this.set(doc, entry)
return this
},
POP(doc: Document, meta: MetaFn) {
let entry = this.get(doc)
if (entry) {
entry.count--
entry.meta.delete(meta)
}
return this
},
SCROLL_PREVENT({ doc, d, meta }: DocEntry) {
let ctx = {
doc,
d,
meta: buildMeta(meta),
}
let steps: ScrollLockStep<any>[] = [
handleIOSLocking(),
adjustScrollbarPadding(),
preventScroll(),
]
// Run all `before` actions together
steps.forEach(({ before }) => before?.(ctx))
// Run all `after` actions together
steps.forEach(({ after }) => after?.(ctx))
},
SCROLL_ALLOW({ d }: DocEntry) {
d.dispose()
},
TEARDOWN({ doc }: DocEntry) {
this.delete(doc)
},
})
// Update the document overflow state when the store changes
// This MUST happen outside of react for this to work properly.
overflows.subscribe(() => {
let docs = overflows.getSnapshot()
let styles = new Map<Document, string | undefined>()
// Read data from all the documents
for (let [doc] of docs) {
styles.set(doc, doc.documentElement.style.overflow)
}
// Write data to all the documents
for (let entry of docs.values()) {
let isHidden = styles.get(entry.doc) === 'hidden'
let isLocked = entry.count !== 0
let willChange = (isLocked && !isHidden) || (!isLocked && isHidden)
if (willChange) {
overflows.dispatch(entry.count > 0 ? 'SCROLL_PREVENT' : 'SCROLL_ALLOW', entry)
}
// We have to clean up after ourselves so we don't leak memory
// Using a WeakMap would be ideal, but it's not iterable
if (entry.count === 0) {
overflows.dispatch('TEARDOWN', entry)
}
}
})
@@ -0,0 +1,9 @@
import { ScrollLockStep } from './overflow-store'
export function preventScroll(): ScrollLockStep {
return {
before({ doc, d }) {
d.style(doc.documentElement, 'overflow', 'hidden')
},
}
}
@@ -0,0 +1,27 @@
import { useIsoMorphicEffect } from '../use-iso-morphic-effect'
import { useStore } from '../../hooks/use-store'
import { overflows } from './overflow-store'
export function useDocumentOverflowLockedEffect(
doc: Document | null,
shouldBeLocked: boolean,
meta: (meta: Record<string, any>) => Record<string, any>
) {
let store = useStore(overflows)
let entry = doc ? store.get(doc) : undefined
let locked = entry ? entry.count > 0 : false
useIsoMorphicEffect(() => {
if (!doc || !shouldBeLocked) {
return
}
// Prevent the document from scrolling
overflows.dispatch('PUSH', doc, meta)
// Allow document to scroll
return () => overflows.dispatch('POP', doc, meta)
}, [shouldBeLocked, doc])
return locked
}
@@ -0,0 +1,6 @@
import { useSyncExternalStore } from '../use-sync-external-store-shim/index'
import { Store } from '../utils/store'
export function useStore<T>(store: Store<T, any>) {
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
}
@@ -0,0 +1,38 @@
// This was taken from the ESM / CJS compatible version found in Remix Router:
// https://github.com/remix-run/react-router/tree/43cc1aacd8b132507618a4a1dd7de3674cd7bcf4/packages/react-router/lib/use-sync-external-store-shim
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react'
import { useSyncExternalStore as client } from './useSyncExternalStoreShimClient'
import { useSyncExternalStore as server } from './useSyncExternalStoreShimServer'
const canUseDOM: boolean = !!(
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
)
const isServerEnvironment = !canUseDOM
const shim = isServerEnvironment ? server : client
type UseSyncExternalStoreFn = <T>(
subscribe: (fn: () => void) => () => void,
getSnapshot: () => T,
// Note: The shim does not use getServerSnapshot, because pre-18 versions of
// React do not expose a way to check if we're hydrating. So users of the shim
// will need to track that themselves and return the correct value
// from `getSnapshot`.
getServerSnapshot?: () => T
) => T
// @ts-ignore
export const useSyncExternalStore: UseSyncExternalStoreFn =
'useSyncExternalStore' in React ? ((r) => r.useSyncExternalStore)(React) : shim
@@ -0,0 +1,152 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as React from 'react'
// Make typescript happy
declare var __DEV__: boolean
/**
* inlined Object.is polyfill to avoid requiring consumers ship their own
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function isPolyfill(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
)
}
const is: (x: any, y: any) => boolean = typeof Object.is === 'function' ? Object.is : isPolyfill
// Intentionally not using named imports because Rollup uses dynamic
// dispatch for CommonJS interop named imports.
const { useState, useEffect, useLayoutEffect, useDebugValue } = React
let didWarnOld18Alpha = false
let didWarnUncachedGetSnapshot = false
// Disclaimer: This shim breaks many of the rules of React, and only works
// because of a very particular set of implementation details and assumptions
// -- change any one of them and it will break. The most important assumption
// is that updates are always synchronous, because concurrent rendering is
// only available in versions of React that also have a built-in
// useSyncExternalStore API. And we only use this shim when the built-in API
// does not exist.
//
// Do not assume that the clever hacks used by this hook also work in general.
// The point of this shim is to replace the need for hacks by other libraries.
export function useSyncExternalStore<T>(
subscribe: (fn: () => void) => () => void,
getSnapshot: () => T,
// Note: The shim does not use getServerSnapshot, because pre-18 versions of
// React do not expose a way to check if we're hydrating. So users of the shim
// will need to track that themselves and return the correct value
// from `getSnapshot`.
getServerSnapshot?: () => T
): T {
if (__DEV__) {
if (!didWarnOld18Alpha) {
if ('startTransition' in React) {
didWarnOld18Alpha = true
console.error(
'You are using an outdated, pre-release alpha of React 18 that ' +
'does not support useSyncExternalStore. The ' +
'use-sync-external-store shim will not work correctly. Upgrade ' +
'to a newer pre-release.'
)
}
}
}
// Read the current snapshot from the store on every render. Again, this
// breaks the rules of React, and only works here because of specific
// implementation details, most importantly that updates are
// always synchronous.
const value = getSnapshot()
if (__DEV__) {
if (!didWarnUncachedGetSnapshot) {
const cachedValue = getSnapshot()
if (!is(value, cachedValue)) {
console.error('The result of getSnapshot should be cached to avoid an infinite loop')
didWarnUncachedGetSnapshot = true
}
}
}
// Because updates are synchronous, we don't queue them. Instead we force a
// re-render whenever the subscribed state changes by updating an some
// arbitrary useState hook. Then, during render, we call getSnapshot to read
// the current value.
//
// Because we don't actually use the state returned by the useState hook, we
// can save a bit of memory by storing other stuff in that slot.
//
// To implement the early bailout, we need to track some things on a mutable
// object. Usually, we would put that in a useRef hook, but we can stash it in
// our useState hook instead.
//
// To force a re-render, we call forceUpdate({inst}). That works because the
// new object always fails an equality check.
const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } })
// Track the latest getSnapshot function with a ref. This needs to be updated
// in the layout phase so we can access it during the tearing check that
// happens on subscribe.
useLayoutEffect(() => {
inst.value = value
inst.getSnapshot = getSnapshot
// Whenever getSnapshot or subscribe changes, we need to check in the
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({ inst })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe, value, getSnapshot])
useEffect(() => {
// Check for changes right before subscribing. Subsequent changes will be
// detected in the subscription handler.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({ inst })
}
const handleStoreChange = () => {
// TODO: Because there is no cross-renderer API for batching updates, it's
// up to the consumer of this library to wrap their subscription event
// with unstable_batchedUpdates. Should we try to detect when this isn't
// the case and print a warning in development?
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({ inst })
}
}
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
useDebugValue(value)
return value
}
function checkIfSnapshotChanged(inst: any) {
const latestGetSnapshot = inst.getSnapshot
const prevValue = inst.value
try {
const nextValue = latestGetSnapshot()
return !is(prevValue, nextValue)
} catch (error) {
return true
}
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export function useSyncExternalStore<T>(
subscribe: (fn: () => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T
): T {
// Note: The shim does not use getServerSnapshot, because pre-18 versions of
// React do not expose a way to check if we're hydrating. So users of the shim
// will need to track that themselves and return the correct value
// from `getSnapshot`.
return getSnapshot()
}
@@ -1,5 +1,7 @@
import { microTask } from './micro-task'
export type Disposables = ReturnType<typeof disposables>
export function disposables() {
let disposables: Function[] = []
let queue: Function[] = []
@@ -69,6 +71,14 @@ export function disposables() {
await handle()
}
},
style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return this.add(() => {
Object.assign(node.style, { [property]: previous })
})
},
}
return api
@@ -0,0 +1,39 @@
type ChangeFn = () => void
type UnsubscribeFn = () => void
type ActionFn<T> = (this: T, ...args: any[]) => T | void
type StoreActions<Key extends string, T> = Record<Key, ActionFn<T>>
export interface Store<T, ActionKey extends string> {
getSnapshot(): T
subscribe(onChange: ChangeFn): UnsubscribeFn
dispatch(action: ActionKey, ...args: any[]): void
}
export function createStore<T, ActionKey extends string>(
initial: () => T,
actions: StoreActions<ActionKey, T>
): Store<T, ActionKey> {
let state: T = initial()
let listeners = new Set<ChangeFn>()
return {
getSnapshot() {
return state
},
subscribe(onChange) {
listeners.add(onChange)
return () => listeners.delete(onChange)
},
dispatch(key: ActionKey, ...args: any[]) {
let newState = actions[key].call(state, ...args)
if (newState) {
state = newState
listeners.forEach((listener) => listener())
}
},
}
}
+3 -1
View File
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Nothing yet!
### Fixed
- Don't break overflow when multiple dialogs are open at the same time ([#2215](https://github.com/tailwindlabs/headlessui/pull/2215))
## [1.7.8] - 2023-01-27
@@ -1,5 +1,14 @@
import { defineComponent, ref, nextTick, h, ConcreteComponent, onMounted } from 'vue'
import { createRenderTemplate, render } from '../../test-utils/vue-testing-library'
import {
defineComponent,
ref,
nextTick,
h,
ConcreteComponent,
onMounted,
PropType,
computed,
} from 'vue'
import { createRenderTemplate, render, screen } from '../../test-utils/vue-testing-library'
import {
Dialog,
@@ -42,13 +51,13 @@ global.IntersectionObserver = class FakeIntersectionObserver {
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
})
})
})
return frames(1)
}
async function frames(count: number) {
for (let n = 0; n <= count; n++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
let TabSentinel = defineComponent({
@@ -441,6 +450,101 @@ describe('Rendering', () => {
expect(document.documentElement.style.overflow).toBe('hidden')
})
)
it(
'scroll locking should work when transitioning between dialogs',
suppressConsoleLogs(async () => {
// While we don't support multiple dialogs
// We at least want to work towards supporting it at some point
// The first step is just making sure that scroll locking works
// when there are multiple dialogs open at the same time
let DialogWrapper = defineComponent({
components: {
TransitionRoot,
Dialog,
},
props: {
id: String,
dialogs: Array as PropType<string[]>,
toggle: Function as PropType<(id: string, state: string) => void>,
},
template: `
<button :id="id_open" @click="toggle(id, 'open')">
Open {{ id }}
</button>
<TransitionRoot as="template" :show="dialogs.includes(id)">
<Dialog @close="toggle(id, 'close')" :data-debug="id">
<button :id="id_close" @click="toggle(id, 'close')">
Close {{ id }}
</button>
</Dialog>
</TransitionRoot>
`,
setup(props) {
return {
id_open: computed(() => `open_${props.id}`),
id_close: computed(() => `close_${props.id}`),
}
},
})
let Example = defineComponent({
components: { DialogWrapper },
template: `
<DialogWrapper id="d1" :dialogs="dialogs" :toggle="toggle" />
<DialogWrapper id="d2" :dialogs="dialogs" :toggle="toggle" />
<DialogWrapper id="d3" :dialogs="dialogs" :toggle="toggle" />
`,
setup() {
let dialogs = ref<string[]>([])
return {
dialogs,
toggle(id: string, state: 'open' | 'close') {
if (state === 'open' && !dialogs.value.includes(id)) {
dialogs.value = [id]
} else if (state === 'close' && dialogs.value.includes(id)) {
dialogs.value = dialogs.value.filter((x) => x !== id)
}
},
}
},
})
renderTemplate(Example)
// No overflow yet
expect(document.documentElement.style.overflow).toBe('')
let open1 = () => document.getElementById('open_d1')
let open2 = () => document.getElementById('open_d2')
let open3 = () => document.getElementById('open_d3')
let close3 = () => document.getElementById('close_d3')
// Open the dialog & expect overflow
await click(open1())
await frames(2)
expect(document.documentElement.style.overflow).toBe('hidden')
// Open the dialog & expect overflow
await click(open2())
await frames(2)
// expect(document.documentElement.style.overflow).toBe('hidden')
// Open the dialog & expect overflow
await click(open3())
await frames(2)
expect(document.documentElement.style.overflow).toBe('hidden')
// At this point only the last dialog should be open
// Close the dialog & dont expect overflow
await click(close3())
await frames(2)
expect(document.documentElement.style.overflow).toBe('')
})
)
})
describe('DialogOverlay', () => {
@@ -33,8 +33,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
import { getOwnerDocument } from '../../utils/owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { disposables } from '../../utils/disposables'
import { isIOS } from '../../utils/platform'
import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow'
import { handleIOSLocking } from '../../hooks/document-overflow/handle-ios-locking'
enum DialogStates {
Open,
@@ -221,114 +221,9 @@ export let Dialog = defineComponent({
})
// Scroll lock
watchEffect((onInvalidate) => {
if (dialogState.value !== DialogStates.Open) return
if (hasParentDialog) return
let owner = ownerDocument.value
if (!owner) return
let d = disposables()
let scrollPosition = window.pageYOffset
function style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return d.add(() => {
Object.assign(node.style, { [property]: previous })
})
}
let documentElement = owner?.documentElement
let ownerWindow = owner.defaultView ?? window
let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
style(documentElement, 'overflow', 'hidden')
if (scrollbarWidthBefore > 0) {
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
}
if (isIOS()) {
style(owner.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
owner,
'click',
(e) => {
if (e.target instanceof HTMLElement) {
try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = owner!.querySelector(hash)
if (el && !resolveAllowedContainers().some((container) => container.contains(el))) {
scrollToElement = el as HTMLElement
}
} catch (err) {}
}
},
true
)
d.addEventListener(
owner,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel
// the event!
if (
e.target instanceof HTMLElement &&
!resolveAllowedContainers().some((container) =>
container.contains(e.target as HTMLElement)
)
) {
e.preventDefault()
}
},
{ passive: false }
)
// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)
// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
})
}
onInvalidate(d.dispose)
})
useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({
containers: [...(meta.containers ?? []), resolveAllowedContainers],
}))
// Trigger close when the FocusTrap gets hidden
watchEffect((onInvalidate) => {
@@ -0,0 +1,25 @@
import { ScrollLockStep } from './overflow-store'
export function adjustScrollbarPadding(): ScrollLockStep {
let scrollbarWidthBefore: number
return {
before({ doc }) {
let documentElement = doc.documentElement
let ownerWindow = doc.defaultView ?? window
scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth
},
after({ doc, d }) {
let documentElement = doc.documentElement
// Account for the change in scrollbar width
// NOTE: This is a bit of a hack, but it's the only way to do this
let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth
let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter
d.style(documentElement, 'paddingRight', `${scrollbarWidth}px`)
},
}
}
@@ -0,0 +1,98 @@
import { isIOS } from '../../utils/platform'
import { ScrollLockStep } from './overflow-store'
interface ContainerMetadata {
containers: (() => HTMLElement[])[]
}
export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
if (!isIOS()) {
return {}
}
let scrollPosition: number
return {
before() {
scrollPosition = window.pageYOffset
},
after({ doc, d, meta }) {
function inAllowedContainer(el: HTMLElement) {
return meta.containers
.flatMap((resolve) => resolve())
.some((container) => container.contains(el))
}
d.style(doc.body, 'marginTop', `-${scrollPosition}px`)
window.scrollTo(0, 0)
// Relatively hacky, but if you click a link like `<a href="#foo">` in the Dialog, and there
// exists an element on the page (outside of the Dialog) with that id, then the browser will
// scroll to that position. However, this is not the case if the element we want to scroll to
// is higher and the browser needs to scroll up, but it doesn't do that.
//
// Let's try and capture that element and store it, so that we can later scroll to it once the
// Dialog closes.
let scrollToElement: HTMLElement | null = null
d.addEventListener(
doc,
'click',
(e) => {
if (!(e.target instanceof HTMLElement)) {
return
}
try {
let anchor = e.target.closest('a')
if (!anchor) return
let { hash } = new URL(anchor.href)
let el = doc.querySelector(hash)
if (el && !inAllowedContainer(el as HTMLElement)) {
scrollToElement = el as HTMLElement
}
} catch (err) {}
},
true
)
d.addEventListener(
doc,
'touchmove',
(e) => {
// Check if we are scrolling inside any of the allowed containers, if not let's cancel the event!
if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) {
e.preventDefault()
}
},
{ passive: false }
)
// Restore scroll position
d.add(() => {
// Before opening the Dialog, we capture the current pageYOffset, and offset the page with
// this value so that we can also scroll to `(0, 0)`.
//
// If we want to restore a few things can happen:
//
// 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely
// restore to the captured value earlier.
// 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a
// link was scrolled into view in the background). Ideally we want to restore to this _new_
// position. To do this, we can take the new value into account with the captured value from
// before.
//
// (Since the value of window.pageYOffset is 0 in the first case, we should be able to
// always sum these values)
window.scrollTo(0, window.pageYOffset + scrollPosition)
// If we captured an element that should be scrolled to, then we can try to do that if the
// element is still connected (aka, still in the DOM).
if (scrollToElement && scrollToElement.isConnected) {
scrollToElement.scrollIntoView({ block: 'nearest' })
scrollToElement = null
}
})
},
}
}
@@ -0,0 +1,118 @@
import { disposables, Disposables } from '../../utils/disposables'
import { createStore } from '../../utils/store'
import { adjustScrollbarPadding } from './adjust-scrollbar-padding'
import { handleIOSLocking } from './handle-ios-locking'
import { preventScroll } from './prevent-scroll'
interface DocEntry {
doc: Document
count: number
d: Disposables
meta: Set<MetaFn>
}
function buildMeta(fns: Iterable<MetaFn>) {
let tmp = {}
for (let fn of fns) {
Object.assign(tmp, fn(tmp))
}
return tmp
}
export type MetaFn = (meta: Record<string, any>) => Record<string, any>
export interface Context<MetaType extends Record<string, any> = any> {
doc: Document
d: Disposables
meta: MetaType
}
export interface ScrollLockStep<MetaType extends Record<string, any> = any> {
before?(ctx: Context<MetaType>): void
after?(ctx: Context<MetaType>): void
}
export let overflows = createStore(() => new Map<Document, DocEntry>(), {
PUSH(doc: Document, meta: MetaFn) {
let entry = this.get(doc) ?? {
doc,
count: 0,
d: disposables(),
meta: new Set(),
}
entry.count++
entry.meta.add(meta)
this.set(doc, entry)
return this
},
POP(doc: Document, meta: MetaFn) {
let entry = this.get(doc)
if (entry) {
entry.count--
entry.meta.delete(meta)
}
return this
},
SCROLL_PREVENT({ doc, d, meta }: DocEntry) {
let ctx = {
doc,
d,
meta: buildMeta(meta),
}
let steps: ScrollLockStep<any>[] = [
handleIOSLocking(),
adjustScrollbarPadding(),
preventScroll(),
]
// Run all `before` actions together
steps.forEach(({ before }) => before?.(ctx))
// Run all `after` actions together
steps.forEach(({ after }) => after?.(ctx))
},
SCROLL_ALLOW({ d }: DocEntry) {
d.dispose()
},
TEARDOWN({ doc }: DocEntry) {
this.delete(doc)
},
})
// Update the document overflow state when the store changes
// This MUST happen outside of react for this to work properly.
overflows.subscribe(() => {
let docs = overflows.getSnapshot()
let styles = new Map<Document, string | undefined>()
// Read data from all the documents
for (let [doc] of docs) {
styles.set(doc, doc.documentElement.style.overflow)
}
// Write data to all the documents
for (let entry of docs.values()) {
let isHidden = styles.get(entry.doc) === 'hidden'
let isLocked = entry.count !== 0
let willChange = (isLocked && !isHidden) || (!isLocked && isHidden)
if (willChange) {
overflows.dispatch(entry.count > 0 ? 'SCROLL_PREVENT' : 'SCROLL_ALLOW', entry)
}
// We have to clean up after ourselves so we don't leak memory
// Using a WeakMap would be ideal, but it's not iterable
if (entry.count === 0) {
overflows.dispatch('TEARDOWN', entry)
}
}
})
@@ -0,0 +1,9 @@
import { ScrollLockStep } from './overflow-store'
export function preventScroll(): ScrollLockStep {
return {
before({ doc, d }) {
d.style(doc.documentElement, 'overflow', 'hidden')
},
}
}
@@ -0,0 +1,43 @@
import { useStore } from '../../hooks/use-store'
import { overflows, MetaFn } from './overflow-store'
import { computed, Ref, watch } from 'vue'
export function useDocumentOverflowLockedEffect(
doc: Ref<Document | null>,
shouldBeLocked: Ref<boolean>,
meta: MetaFn
) {
let store = useStore(overflows)
let locked = computed(() => {
let entry = doc.value ? store.value.get(doc.value) : undefined
return entry ? entry.count > 0 : false
})
watch(
[doc, shouldBeLocked],
([doc, shouldBeLocked], [oldDoc], onInvalidate) => {
if (!doc || !shouldBeLocked) {
return
}
// Prevent the document from scrolling
overflows.dispatch('PUSH', doc, meta)
// Allow document to scroll
let didRunCleanup = false
onInvalidate(() => {
if (didRunCleanup) return
overflows.dispatch('POP', oldDoc ?? doc, meta)
// This shouldn't be necessary, but it is.
// Seems like a Vue bug.
didRunCleanup = true
})
},
{
immediate: true,
}
)
return locked
}
@@ -0,0 +1,14 @@
import { onUnmounted, shallowRef } from 'vue'
import { Store } from '../utils/store'
export function useStore<T>(store: Store<T, any>) {
let state = shallowRef(store.getSnapshot())
onUnmounted(
store.subscribe(() => {
state.value = store.getSnapshot()
})
)
return state
}
@@ -1,3 +1,5 @@
export type Disposables = ReturnType<typeof disposables>
export function disposables() {
let disposables: Function[] = []
let queue: Function[] = []
@@ -37,6 +39,14 @@ export function disposables() {
disposables.push(cb)
},
style(node: HTMLElement, property: string, value: string) {
let previous = node.style.getPropertyValue(property)
Object.assign(node.style, { [property]: value })
return this.add(() => {
Object.assign(node.style, { [property]: previous })
})
},
dispose() {
for (let dispose of disposables.splice(0)) {
dispose()
@@ -0,0 +1,20 @@
export interface Middleware<ReqType> {
(request: ReqType, next: (req: ReqType) => void): void
}
export function pipeline<ReqType>(handlers: Middleware<ReqType>[]) {
return (request: ReqType, andThen?: (req: ReqType) => void) => {
let index = 0
function next() {
let handler = handlers[index++]
if (handler) {
handler(request, next)
} else if (andThen) {
andThen(request)
}
}
next()
}
}
@@ -0,0 +1,39 @@
type ChangeFn = () => void
type UnsubscribeFn = () => void
type ActionFn<T> = (this: T, ...args: any[]) => T | void
type StoreActions<Key extends string, T> = Record<Key, ActionFn<T>>
export interface Store<T, ActionKey extends string> {
getSnapshot(): T
subscribe(onChange: ChangeFn): UnsubscribeFn
dispatch(action: ActionKey, ...args: any[]): void
}
export function createStore<T, ActionKey extends string>(
initial: () => T,
actions: StoreActions<ActionKey, T>
): Store<T, ActionKey> {
let state: T = initial()
let listeners = new Set<ChangeFn>()
return {
getSnapshot() {
return state
},
subscribe(onChange) {
listeners.add(onChange)
return () => listeners.delete(onChange)
},
dispatch(key: ActionKey, ...args: any[]) {
let newState = actions[key].call(state, ...args)
if (newState) {
state = newState
listeners.forEach((listener) => listener())
}
},
}
}
@@ -0,0 +1,74 @@
import { Fragment, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'
function MyDialog({ open, close }) {
return (
<>
<Transition show={open} as={Fragment}>
<Dialog onClose={close} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition duration-500 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-500 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed left-0 top-0 bottom-0 flex items-center justify-center bg-red-500 p-4">
<Dialog.Panel className="mx-auto w-48 rounded bg-white p-4">
<p className="my-2">Gray area should be scrollable</p>
<p className="h-32 overflow-y-scroll border bg-gray-100">
Are you sure you want to deactivate your account? All of your data will be
permanently removed. This action cannot be undone.
</p>
<p>Colored area on the right should not be scrollable</p>
<a
href="#foo"
onClick={() => {
setTimeout(() => {
close()
}, 2000)
}}
>
Click me to close dialog and scroll to Foo
</a>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
</>
)
}
export default function App() {
let [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<MyDialog open={isOpen} close={() => setIsOpen(false)} />
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
<a id="foo" className="block w-full bg-pink-500 p-12">
Hello from Foo!
</a>
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
<div className="h-[50vh]" />
</div>
)
}
@@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
import { useState } from 'react'
import { Dialog } from '@headlessui/react'
if (typeof document !== 'undefined') {
class MyCustomElement extends HTMLElement {
shadow: ShadowRoot
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'closed' })
}
connectedCallback() {
let button = document.createElement('button')
button.textContent = 'Inside shadow root (closed)'
this.shadow.appendChild(button)
}
}
customElements.define('my-custom-element', MyCustomElement)
}
function ShadowChildren({ id }: { id: string }) {
let container = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!container.current || container.current.shadowRoot) {
return
}
let shadowRoot = container.current.attachShadow({ mode: 'open' })
let button = document.createElement('button')
button.id = id
button.style.display = 'block'
button.textContent = 'Inside shadow root (open)'
let mce = document.createElement('my-custom-element')
shadowRoot.appendChild(button)
shadowRoot.appendChild(mce)
}, [])
return <div ref={container}></div>
}
export default function App() {
const [open, setOpen] = useState(false)
return (
<div>
<button
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
onClick={() => setOpen(true)}
>
open
</button>
<Dialog open={open} onClose={() => setOpen(false)}>
<div className="fixed inset-0 z-50 bg-gray-900/75 backdrop-blur-lg">
<div>
<button
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
id="btn_outside_light"
>
Outside shadow root
</button>
<ShadowChildren id="btn_outside_shadow" />
</div>
</div>
<Dialog.Panel className="fixed top-16 left-16 z-50 h-64 w-64 rounded-lg border border-black/10 bg-white bg-clip-padding p-12 shadow-lg">
<div>
<button
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
id="btn_inside_light"
>
Outside shadow root
</button>
<ShadowChildren id="btn_inside_shadow" />
</div>
</Dialog.Panel>
</Dialog>
</div>
)
}
+4 -4
View File
@@ -28,12 +28,12 @@ resolverOptions+=('/**/*.{ts,tsx}')
resolverOptions+=('--ignore=.test.,__mocks__')
INPUT_FILES=$($resolver ${resolverOptions[@]})
NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} &
NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} &
NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} &
NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} &
# Common JS
NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ &
NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ &
NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} $@ &
NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="true" ${sharedOptions[@]} $@ &
# Generate types
tsc --emitDeclarationOnly --outDir $DST &