/**
* This Source Code is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) Infonomic Company Limited
*/
import { act } from 'react'
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FormProvider } from './form-context'
import { useFormDomScope, useScopedDomId } from './form-dom-scope'
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
describe('form DOM scope', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
const render = (node: React.ReactNode) => {
act(() => {
root.render(node)
})
}
it('gives two concurrently mounted providers different scopes', () => {
const scopes: string[] = []
const Probe = () => {
scopes.push(useFormDomScope())
return null
}
render(
<>
>
)
expect(scopes).toHaveLength(2)
expect(new Set(scopes).size).toBe(2)
})
it('derives a stable id for the same base across re-renders', () => {
const seen: string[] = []
const Probe = () => {
seen.push(useScopedDomId('title'))
return null
}
render(
)
render(
)
expect(seen.length).toBeGreaterThanOrEqual(2)
expect(new Set(seen).size).toBe(1)
})
it('scopes the base so two forms derive different ids from one field name', () => {
const ids: string[] = []
const Probe = () => {
ids.push(useScopedDomId('title'))
return null
}
render(
<>
>
)
expect(new Set(ids).size).toBe(2)
for (const id of ids) expect(id.endsWith('title')).toBe(true)
})
it('returns a caller-supplied id unchanged rather than scoping it twice', () => {
let derived = ''
const Probe = () => {
derived = useScopedDomId('title', 'explicit-id')
return null
}
render(
)
expect(derived).toBe('explicit-id')
})
/**
* Field widgets render outside a form provider in places — the admin user and
* role screens compose them directly — so the hook has to degrade to the bare
* base rather than throw or invent a scope.
*/
it('falls back to the bare base outside a provider', () => {
let derived = ''
let scope = 'unset'
const Probe = () => {
scope = useFormDomScope()
derived = useScopedDomId('title')
return null
}
render()
expect(scope).toBe('')
expect(derived).toBe('title')
})
it('produces a non-empty scope inside a provider', () => {
const scopes: string[] = []
const Probe = () => {
scopes.push(useFormDomScope())
return null
}
render(
)
expect(scopes[0]).not.toBe('')
})
/**
* The reason the scope comes from `useId` rather than a counter or a random
* value: the server render and the hydration pass must agree. If they do not,
* React discards the server-rendered attributes and every `for` and
* `aria-labelledby` written during SSR points at an id that no longer exists.
*
* This renders on the server for real and hydrates that markup, rather than
* inspecting the shape of the generated string.
*/
it('derives the same id on the server render and on hydration', async () => {
let renderedId = ''
const Probe = () => {
const id = useScopedDomId('title')
renderedId = id
return title
}
const tree = (
)
const html = renderToString(tree)
const serverId = /id="([^"]+)"/.exec(html)?.[1]
expect(serverId).toBeTruthy()
const host = document.createElement('div')
host.innerHTML = html
document.body.appendChild(host)
// React reports a hydration mismatch through console.error rather than by
// throwing, so silence alone is not evidence — collect and assert on it.
const consoleErrors: unknown[][] = []
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
consoleErrors.push(args)
})
let hydrated: Root | undefined
await act(async () => {
hydrated = hydrateRoot(host, tree)
})
spy.mockRestore()
expect(consoleErrors).toEqual([])
expect(renderedId).toBe(serverId)
expect(host.querySelector('span')?.id).toBe(serverId)
act(() => {
hydrated?.unmount()
})
host.remove()
})
})