Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/vinext/src/client/app-router-announcer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";

const ANNOUNCER_TAG = "next-route-announcer";
const ANNOUNCER_ID = "__next-route-announcer__";

function getOrCreateAnnouncerNode(): HTMLElement {
const existingHost = document.querySelector(ANNOUNCER_TAG);
const existingNode = existingHost?.shadowRoot?.querySelector<HTMLElement>(`#${ANNOUNCER_ID}`);
if (existingNode) return existingNode;

const host = document.createElement(ANNOUNCER_TAG);
host.style.position = "absolute";

const announcer = document.createElement("div");
announcer.id = ANNOUNCER_ID;
announcer.setAttribute("aria-live", "assertive");
announcer.setAttribute("role", "alert");
announcer.style.cssText =
"position:absolute;border:0;height:1px;margin:-1px;padding:0;width:1px;clip:rect(0 0 0 0);overflow:hidden;white-space:nowrap;word-wrap:normal";

host.attachShadow({ mode: "open" }).appendChild(announcer);
document.body.appendChild(host);
return announcer;
}

function readRouteAnnouncement(): string {
if (document.title) return document.title;

const heading = document.querySelector("h1");
return heading?.innerText || heading?.textContent || "";
}

/**
* Re-evaluate the accessible route name after each approved visible commit.
*
* This signal is broader than soft navigation, so an announcement is written
* only when the resulting title or heading changed. An aborted or superseded
* render cannot advance the signal and announce a route the user never saw.
* The first effect pass only records the initial name because the browser
* already announces a full document load.
*/
export function AppRouterAnnouncer({ commitVersion }: { commitVersion: number }) {
const [portalNode, setPortalNode] = useState<HTMLElement | null>(null);
const [routeAnnouncement, setRouteAnnouncement] = useState("");
const previousAnnouncement = useRef<string | undefined>(undefined);

useEffect(() => {
const announcer = getOrCreateAnnouncerNode();
setPortalNode(announcer);

return () => {
const host = document.querySelector(ANNOUNCER_TAG);
if (host?.isConnected) host.remove();
};
}, []);

useEffect(() => {
const currentAnnouncement = readRouteAnnouncement();
if (
previousAnnouncement.current !== undefined &&
previousAnnouncement.current !== currentAnnouncement
) {
setRouteAnnouncement(currentAnnouncement);
}
previousAnnouncement.current = currentAnnouncement;
}, [commitVersion]);

return portalNode ? createPortal(routeAnnouncement, portalNode) : null;
}
12 changes: 10 additions & 2 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />

import {
Fragment,
createElement,
startTransition,
use,
Expand All @@ -19,6 +20,7 @@ import { flushSync } from "react-dom";
import { createRoot, hydrateRoot } from "react-dom/client";
import "../client/instrumentation-client.js";
import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js";
import { AppRouterAnnouncer } from "../client/app-router-announcer.js";
import {
__basePath,
appRouterInstance,
Expand Down Expand Up @@ -1201,16 +1203,22 @@ function BrowserRoot({
// oxlint-disable-next-line react/no-children-prop -- This generated browser entry is TypeScript, not TSX.
children: scrollScopedTree,
});
const accessibleTree = createElement(
Fragment,
null,
rootErrorTree,
createElement(AppRouterAnnouncer, { commitVersion: treeState.visibleCommitVersion }),
);

const ClientNavigationRenderContext = getClientNavigationRenderContext();
if (!ClientNavigationRenderContext) {
return rootErrorTree;
return accessibleTree;
}

return createElement(
ClientNavigationRenderContext.Provider,
{ value: treeState.navigationSnapshot },
rootErrorTree,
accessibleTree,
);
}

Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/app-router/nextjs-compat/metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ import { waitForAppRouterHydration } from "../../helpers";
const BASE = "http://localhost:4174";

test.describe("Next.js compat: metadata (browser)", () => {
// Ported from Next.js: test/e2e/app-dir/app-a11y/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-a11y/index.test.ts
test("route announcer stays empty initially and announces a soft navigation", async ({
page,
}) => {
const readAnnouncement = () =>
page.evaluate(() => {
const host = document.querySelector("next-route-announcer");
return host?.shadowRoot?.querySelector("#__next-route-announcer__")?.textContent ?? null;
});

await page.goto(`${BASE}/nextjs-compat/nav-link-test`);
await waitForAppRouterHydration(page);
await expect.poll(readAnnouncement).toBe("");

await page.click("#link-to-title");
await expect(page).toHaveTitle("this is the page title");
await expect.poll(readAnnouncement).toBe("this is the page title");
});

// Next.js: 'should support title and description'
// Source: metadata.test.ts#L25-L32
test("document.title matches metadata export", async ({ page }) => {
Expand Down
Loading