diff --git a/packages/next/src/next-devtools/dev-overlay.browser.tsx b/packages/next/src/next-devtools/dev-overlay.browser.tsx index 216eb2caf1e..e6977686f00 100644 --- a/packages/next/src/next-devtools/dev-overlay.browser.tsx +++ b/packages/next/src/next-devtools/dev-overlay.browser.tsx @@ -27,6 +27,11 @@ import type { DebugInfo } from './shared/types' import { DevOverlay } from './dev-overlay/dev-overlay' import type { DevIndicatorServerState } from '../server/dev/dev-indicator-server-state' import type { VersionInfo } from '../server/dev/parse-version-info' +import { + insertSegmentNode, + removeSegmentNode, + type SegmentNode, +} from './dev-overlay/segment-explorer' export interface Dispatcher { onBuildOk(): void @@ -46,6 +51,14 @@ export interface Dispatcher { buildingIndicatorShow(): void renderingIndicatorHide(): void renderingIndicatorShow(): void + segmentExplorerNodeAdd( + nodeType: SegmentNode['type'], + pagePath: SegmentNode['pagePath'] + ): void + segmentExplorerNodeRemove( + nodeType: SegmentNode['type'], + pagePath: SegmentNode['pagePath'] + ): void } type Dispatch = ReturnType[1] @@ -131,6 +144,24 @@ export const dispatcher: Dispatcher = { renderingIndicatorShow: createQueuable((dispatch: Dispatch) => { dispatch({ type: ACTION_RENDERING_INDICATOR_SHOW }) }), + segmentExplorerNodeAdd: createQueuable( + ( + _: Dispatch, + nodeType: SegmentNode['type'], + pagePath: SegmentNode['pagePath'] + ) => { + insertSegmentNode({ type: nodeType, pagePath }) + } + ), + segmentExplorerNodeRemove: createQueuable( + ( + _: Dispatch, + nodeType: SegmentNode['type'], + pagePath: SegmentNode['pagePath'] + ) => { + removeSegmentNode({ type: nodeType, pagePath }) + } + ), } function replayQueuedEvents(dispatch: NonNullable) { diff --git a/packages/next/src/next-devtools/dev-overlay/components/overview/segment-explorer.tsx b/packages/next/src/next-devtools/dev-overlay/components/overview/segment-explorer.tsx index ab14394535a..01c803cde24 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/overview/segment-explorer.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/overview/segment-explorer.tsx @@ -2,24 +2,16 @@ import type { HTMLProps } from 'react' import { css } from '../../utils/css' import type { DevToolsInfoPropsCore } from '../errors/dev-tools-indicator/dev-tools-info/dev-tools-info' import { DevToolsInfo } from '../errors/dev-tools-indicator/dev-tools-info/dev-tools-info' -import { - type SegmentNode, - useSegmentTreeClientState, -} from '../../../../shared/lib/devtool/app-segment-tree' -import type { Trie, TrieNode } from '../../../../shared/lib/devtool/trie' - -function PageSegmentTree({ tree }: { tree: Trie | undefined }) { - if (!tree) { - return null - } +import { useSegmentTree, type SegmentTrieNode } from '../../segment-explorer' + +function PageSegmentTree({ tree }: { tree: SegmentTrieNode }) { return (
| undefined }) { } function PageSegmentTreeLayerPresentation({ - tree, segment, parentSegment, node, level, }: { - tree: Trie segment: string parentSegment: string - node: TrieNode + node: SegmentTrieNode level: number }) { const pagePath = node.value?.pagePath || '' @@ -111,7 +101,6 @@ function PageSegmentTreeLayerPresentation({ key={childSegment} segment={childSegment} parentSegment={segment} - tree={tree} node={child} level={hasFileChildren ? level + 1 : level} /> @@ -124,14 +113,11 @@ function PageSegmentTreeLayerPresentation({ export function SegmentsExplorer( props: DevToolsInfoPropsCore & HTMLProps ) { - const ctx = useSegmentTreeClientState() - if (!ctx) { - return null - } + const tree = useSegmentTree() return ( - + ) } diff --git a/packages/next/src/next-devtools/dev-overlay/segment-explorer.test.tsx b/packages/next/src/next-devtools/dev-overlay/segment-explorer.test.tsx new file mode 100644 index 00000000000..752e501ce37 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/segment-explorer.test.tsx @@ -0,0 +1,204 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable @next/internal/typechecked-require -- Not a prod file */ +/* eslint-disable import/no-extraneous-dependencies -- Not a prod file */ + +import type * as SegmentExplorer from './segment-explorer' + +describe('Segment Explorer', () => { + let cleanup: typeof import('@testing-library/react').cleanup + let renderHook: typeof import('@testing-library/react').renderHook + let useSegmentTree: typeof SegmentExplorer.useSegmentTree + let insertSegmentNode: typeof SegmentExplorer.insertSegmentNode + let removeSegmentNode: typeof SegmentExplorer.removeSegmentNode + + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + + const segmentExplorer = require('./segment-explorer') + useSegmentTree = segmentExplorer.useSegmentTree + insertSegmentNode = segmentExplorer.insertSegmentNode + removeSegmentNode = segmentExplorer.removeSegmentNode + const rtl = require('@testing-library/react/pure') + renderHook = rtl.renderHook + cleanup = rtl.cleanup + }) + + afterEach(() => { + cleanup() + }) + + test('add complex structure', () => { + insertSegmentNode({ pagePath: '/a/page.js', type: 'page' }) + insertSegmentNode({ pagePath: '/a/layout.js', type: 'layout' }) + insertSegmentNode({ pagePath: '/layout.js', type: 'layout' }) + + const { result } = renderHook(useSegmentTree) + + expect(result.current).toEqual({ + children: { + '': { + children: { + a: { + children: { + 'layout.js': { + children: {}, + value: { + pagePath: '/a/layout.js', + type: 'layout', + }, + }, + 'page.js': { + children: {}, + value: { + pagePath: '/a/page.js', + type: 'page', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/layout.js', + type: 'layout', + }, + }, + }, + value: undefined, + }, + }, + value: undefined, + }) + }) + + test.failing('remove node in the middle', () => { + insertSegmentNode({ pagePath: '/a/b/@sidebar/page.js', type: 'page' }) + insertSegmentNode({ pagePath: '/a/b/page.js', type: 'page' }) + insertSegmentNode({ pagePath: '/a/b/layout.js', type: 'layout' }) + insertSegmentNode({ pagePath: '/a/layout.js', type: 'layout' }) + insertSegmentNode({ pagePath: '/layout.js', type: 'layout' }) + + const { result } = renderHook(useSegmentTree) + + expect(result.current).toEqual({ + children: { + '': { + children: { + a: { + children: { + b: { + children: { + '@sidebar': { + children: { + 'page.js': { + children: {}, + value: { + pagePath: '/a/b/@sidebar/page.js', + type: 'page', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/a/b/layout.js', + type: 'layout', + }, + }, + 'page.js': { + children: {}, + value: { + pagePath: '/a/b/page.js', + type: 'page', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/a/layout.js', + type: 'layout', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/layout.js', + type: 'layout', + }, + }, + }, + value: undefined, + }, + }, + value: undefined, + }) + + removeSegmentNode({ pagePath: '/a/b/layout.js', type: 'layout' }) + + expect(result.current).toEqual({ + children: { + '': { + children: { + a: { + children: { + b: { + children: { + '@sidebar': { + children: { + 'page.js': { + children: {}, + value: { + pagePath: '/a/b/@sidebar/page.js', + type: 'page', + }, + }, + }, + value: undefined, + }, + 'page.js': { + children: {}, + value: { + pagePath: '/a/b/page.js', + type: 'page', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/a/layout.js', + type: 'layout', + }, + }, + }, + value: undefined, + }, + 'layout.js': { + children: {}, + value: { + pagePath: '/layout.js', + type: 'layout', + }, + }, + }, + value: undefined, + }, + }, + value: undefined, + }) + }) +}) diff --git a/packages/next/src/next-devtools/dev-overlay/segment-explorer.ts b/packages/next/src/next-devtools/dev-overlay/segment-explorer.ts new file mode 100644 index 00000000000..826149b923c --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/segment-explorer.ts @@ -0,0 +1,124 @@ +import { useSyncExternalStore } from 'react' + +/** + * Trie data structure for storing and searching paths + * + * This can be used to store app router paths and search for them efficiently. + * e.g. + * + * [trie root] + * ├── layout.js + * ├── page.js + * ├── blog + * ├── layout.js + * ├── page.js + * ├── [slug] + * ├── layout.js + * ├── page.js + **/ + +type TrieNode = { + value: Value | undefined + children: { + [key: string]: TrieNode | undefined + } +} + +type Trie = { + insert: (value: Value) => void + remove: (value: Value) => void + getRoot: () => TrieNode +} + +const listeners = new Set<() => void>() +const createSegmentTreeStore = (): { + subscribe: (callback: () => void) => () => void + getSnapshot: () => SegmentTrieNode + getServerSnapshot: () => SegmentTrieNode +} => { + // return a store that can be used by useSyncExternalStore + return { + subscribe: (callback) => { + listeners.add(callback) + return () => listeners.delete(callback) + }, + getSnapshot: () => { + return trie.getRoot() + }, + getServerSnapshot: () => { + return trie.getRoot() + }, + } +} + +// TODO: Move the Segment Tree into React State +const { subscribe, getSnapshot, getServerSnapshot } = createSegmentTreeStore() + +function createTrie({ + getKey = (k) => k as unknown as string, +}: { + getKey: (k: Value) => string +}): Trie { + const root: TrieNode = { + value: undefined, + children: {}, + } + + function markUpdated() { + for (const listener of listeners) { + listener() + } + } + + function insert(value: Value) { + let currentNode = root + const key = getKey(value) + const segments = key.split('/') + + for (const segment of segments) { + if (!currentNode.children[segment]) { + currentNode.children[segment] = { + value: undefined, + // Skip value for intermediate nodes + children: {}, + } + } + currentNode = currentNode.children[segment] + } + + currentNode.value = value + + markUpdated() + } + + function remove(_: Value) { + // TODO Implement remove functionality + + markUpdated() + } + + function getRoot(): TrieNode { + return root + } + + return { insert, remove, getRoot } +} + +export type SegmentNode = { + type: string + pagePath: string +} + +export type SegmentTrie = Trie +export type SegmentTrieNode = TrieNode + +const trie: SegmentTrie = createTrie({ + getKey: (item) => item.pagePath, +}) +export const insertSegmentNode = trie.insert +export const removeSegmentNode = trie.remove + +export function useSegmentTree(): SegmentTrieNode { + const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + return state +} diff --git a/packages/next/src/next-devtools/userspace/app/segment-explorer.ts b/packages/next/src/next-devtools/userspace/app/segment-explorer.ts new file mode 100644 index 00000000000..40175a51fce --- /dev/null +++ b/packages/next/src/next-devtools/userspace/app/segment-explorer.ts @@ -0,0 +1,23 @@ +'use client' +import type { ReactNode } from 'react' +import { useEffect } from 'react' +import { dispatcher } from 'next/dist/compiled/next-devtools' + +export function SegmentViewNode({ + type, + pagePath, + children, +}: { + type: string + pagePath: string + children: ReactNode +}) { + useEffect(() => { + dispatcher.segmentExplorerNodeAdd(type, pagePath) + return () => { + dispatcher.segmentExplorerNodeRemove(type, pagePath) + } + }, [type, pagePath]) + + return children +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index ddad463e02c..8068564e94e 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -45,12 +45,12 @@ import { Postpone } from './rsc/postpone' import { taintObjectReference } from './rsc/taint' export { collectSegmentData } from './collect-segment-data' -let SegmentViewNode: typeof import('../../shared/lib/devtool/app-segment-tree').SegmentViewNode = +let SegmentViewNode: typeof import('../../next-devtools/userspace/app/segment-explorer').SegmentViewNode = () => null if (process.env.NODE_ENV === 'development') { - const appSegmentTree: typeof import('../../shared/lib/devtool/app-segment-tree') = - require('../../shared/lib/devtool/app-segment-tree') as typeof import('../../shared/lib/devtool/app-segment-tree') - SegmentViewNode = appSegmentTree.SegmentViewNode + SegmentViewNode = ( + require('../../next-devtools/userspace/app/segment-explorer') as typeof import('../../next-devtools/userspace/app/segment-explorer') + ).SegmentViewNode } // patchFetch makes use of APIs such as `React.unstable_postpone` which are only available diff --git a/packages/next/src/shared/lib/devtool/app-segment-tree.tsx b/packages/next/src/shared/lib/devtool/app-segment-tree.tsx deleted file mode 100644 index e8515edcda6..00000000000 --- a/packages/next/src/shared/lib/devtool/app-segment-tree.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client' - -import { type ReactNode, useEffect, useSyncExternalStore } from 'react' -import { createTrie, type Trie } from './trie' - -export type SegmentNode = { - type: string - pagePath: string -} - -type DevtoolClientState = { - tree?: Trie -} - -const DEFAULT_CLIENT_STATE = - typeof window === 'undefined' - ? undefined - : createTrie({ - getKey: (item) => item.pagePath, - }) - -declare global { - interface Window { - __NEXT_DEVTOOLS_CLIENT_STATE?: DevtoolClientState - } -} - -function getSegmentTreeClientState(): DevtoolClientState { - if (typeof window === 'undefined') { - return {} - } - if (!window.__NEXT_DEVTOOLS_CLIENT_STATE) { - window.__NEXT_DEVTOOLS_CLIENT_STATE = { - // Initial state - tree: DEFAULT_CLIENT_STATE, - } - } - return window.__NEXT_DEVTOOLS_CLIENT_STATE -} - -const listeners = typeof window === 'undefined' ? null : new Set<() => void>() - -const createSegmentTreeStore = (): { - subscribe: (callback: () => void) => () => void - getSnapshot: () => DevtoolClientState - getServerSnapshot: () => undefined -} => { - if (typeof window === 'undefined') { - return { - subscribe: () => () => void 0, - getSnapshot: () => ({}), - getServerSnapshot: () => undefined, - } - } - - // return a store that can be used by useSyncExternalStore - return { - subscribe: (callback) => { - listeners?.add(callback) - return () => listeners?.delete(callback) - }, - getSnapshot: () => { - return getSegmentTreeClientState() - }, - getServerSnapshot: () => { - return undefined - }, - } -} - -const { subscribe, getSnapshot, getServerSnapshot } = createSegmentTreeStore() - -export function SegmentViewNode({ - type, - pagePath, - children, -}: { - type: string - pagePath: string - children: ReactNode -}) { - const clientState = getSegmentTreeClientState() - const tree = clientState.tree - - useEffect(() => { - if (!tree) { - return - } - tree.insert({ - type, - pagePath, - }) - }, [type, pagePath, tree]) - - return children -} - -export function useSegmentTreeClientState() { - const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) - return state -} diff --git a/packages/next/src/shared/lib/devtool/trie.ts b/packages/next/src/shared/lib/devtool/trie.ts deleted file mode 100644 index 700d99df9ed..00000000000 --- a/packages/next/src/shared/lib/devtool/trie.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Trie data structure for storing and searching paths - * - * This can be used to store app router paths and search for them efficiently. - * e.g. - * - * [trie root] - * ├── layout.js - * ├── page.js - * ├── blog - * ├── layout.js - * ├── page.js - * ├── [slug] - * ├── layout.js - * ├── page.js - **/ - -export type TrieNode = { - value?: Value - children: { - [key: string]: TrieNode | undefined - } -} - -export type Trie = { - insert: (value: Value) => void - getRoot: () => TrieNode -} - -export function createTrie({ - getKey = (k) => k as unknown as string, -}: { - getKey: (k: Value) => string -}): Trie { - const root: TrieNode = { - value: undefined, - children: {}, - } - - function insert(value: Value) { - let currentNode = root - const key = getKey(value) - const segments = key.split('/') - - for (const segment of segments) { - if (!currentNode.children[segment]) { - currentNode.children[segment] = { - // Skip value for intermediate nodes - children: {}, - } - } - currentNode = currentNode.children[segment] - } - - currentNode.value = value - } - - function getRoot(): TrieNode { - return root - } - - return { insert, getRoot } -} diff --git a/test/development/app-dir/segment-explorer/app/soft-navigation/a/page.tsx b/test/development/app-dir/segment-explorer/app/soft-navigation/a/page.tsx new file mode 100644 index 00000000000..b2d3970cdc5 --- /dev/null +++ b/test/development/app-dir/segment-explorer/app/soft-navigation/a/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function SoftNavigationAPage() { + return ( + <> +

Page A

+ Go to Page b + + ) +} diff --git a/test/development/app-dir/segment-explorer/app/soft-navigation/b/page.tsx b/test/development/app-dir/segment-explorer/app/soft-navigation/b/page.tsx new file mode 100644 index 00000000000..2bfa07e5c50 --- /dev/null +++ b/test/development/app-dir/segment-explorer/app/soft-navigation/b/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function SoftNavigationBPage() { + return ( + <> +

Page B

+ Go to Page a + + ) +} diff --git a/test/development/app-dir/segment-explorer/segment-explorer.test.ts b/test/development/app-dir/segment-explorer/segment-explorer.test.ts index 38c0bcafb7e..81b29df7876 100644 --- a/test/development/app-dir/segment-explorer/segment-explorer.test.ts +++ b/test/development/app-dir/segment-explorer/segment-explorer.test.ts @@ -1,5 +1,5 @@ import { nextTestSetup } from 'e2e-utils' -import { openDevToolsIndicatorPopover } from 'next-test-utils' +import { openDevToolsIndicatorPopover, retry } from 'next-test-utils' import { Playwright } from 'next-webdriver' async function getSegmentExplorerContent(browser: Playwright) { @@ -46,4 +46,24 @@ describe('segment-explorer', () => { gridpage.tsx" `) }) + + it('should cleanup on soft navigation', async () => { + const browser = await next.browser('/soft-navigation/a') + expect(await getSegmentExplorerContent(browser)).toMatchInlineSnapshot(` + "applayout.tsx + apage.tsx" + `) + + await browser.elementByCss('[href="/soft-navigation/b"]').click() + await retry(async () => { + expect(await browser.elementByCss('body').text()).toContain('Page B') + }) + + // FIXME: Should no longer contain /soft-navigation/a/page.js + expect(await getSegmentExplorerContent(browser)).toMatchInlineSnapshot(` + "applayout.tsx + apage.tsx + bpage.tsx" + `) + }) })