Skip to content

Commit f00232c

Browse files
huozhiRobPruzan
authored andcommitted
[segment explorer] capture defined boundaries (#81232)
Capture defined boundaries (not-found / error / loading) and used to filter which one is available for boundary triggers The trie will now hold all the defined boundaries, but what's different from the existing rendered boundaries is: Here's the difference of new defined boundary trie nodes: ``` { type: "boundary:<type>" pagePath: "<pagePath>@boundary" } ``` We add a `boundary:` prefix to determine if it's node representing existence; We add a `@boundary` suffix to the filename cause the <pagePath> part is still need to use for indexing in the trie, but the `@boundary` doesn't matter yet. We'll remove it once we start using the file name in the dropdown panel Closes NEXT-4330 ### Example When there's only one not-found.tsx, allow to trigger the not-found boundary and disable the rest. https://github.com/user-attachments/assets/eb5c49a7-4fd2-496b-b778-fb66581908b3
1 parent 69b198a commit f00232c

File tree

8 files changed

+145
-32
lines changed

8 files changed

+145
-32
lines changed

packages/next/src/client/components/layout-router.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ export default function OuterLayoutRouter({
507507
forbidden,
508508
unauthorized,
509509
gracefullyDegrade,
510+
segmentViewBoundaries,
510511
}: {
511512
parallelRouterKey: string
512513
error: ErrorComponent | undefined
@@ -519,6 +520,7 @@ export default function OuterLayoutRouter({
519520
forbidden: React.ReactNode | undefined
520521
unauthorized: React.ReactNode | undefined
521522
gracefullyDegrade?: boolean
523+
segmentViewBoundaries?: React.ReactNode
522524
}) {
523525
const context = useContext(LayoutRouterContext)
524526
if (!context) {
@@ -659,13 +661,14 @@ export default function OuterLayoutRouter({
659661
)
660662

661663
if (process.env.NODE_ENV !== 'production') {
662-
const SegmentStateProvider = (
664+
const { SegmentStateProvider } =
663665
require('../../next-devtools/userspace/app/segment-explorer-node') as typeof import('../../next-devtools/userspace/app/segment-explorer-node')
664-
)
665-
.SegmentStateProvider as typeof import('../../next-devtools/userspace/app/segment-explorer-node').SegmentStateProvider as typeof import('../../next-devtools/userspace/app/segment-explorer-node').SegmentStateProvider
666666

667667
child = (
668-
<SegmentStateProvider key={stateKey}>{child}</SegmentStateProvider>
668+
<SegmentStateProvider key={stateKey}>
669+
{child}
670+
{segmentViewBoundaries}
671+
</SegmentStateProvider>
669672
)
670673
}
671674

packages/next/src/next-devtools/dev-overlay/components/overview/segment-boundary-trigger.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import type { SegmentNodeState } from '../../../userspace/app/segment-explorer-n
55
export function SegmentBoundaryTrigger({
66
onSelectBoundary,
77
offset,
8+
boundaries,
89
}: {
910
onSelectBoundary: SegmentNodeState['setBoundaryType']
1011
offset: number
12+
boundaries: Record<'not-found' | 'loading' | 'error', string | null>
1113
}) {
1214
const [shadowRoot] = useState<ShadowRoot>(() => {
1315
const ownerDocument = document
@@ -17,9 +19,24 @@ export function SegmentBoundaryTrigger({
1719
const shadowRootRef = useRef<ShadowRoot>(shadowRoot)
1820

1921
const triggerOptions = [
20-
{ label: 'Trigger Loading', value: 'loading', icon: <LoadingIcon /> },
21-
{ label: 'Trigger Error', value: 'error', icon: <ErrorIcon /> },
22-
{ label: 'Trigger Not Found', value: 'not-found', icon: <NotFoundIcon /> },
22+
{
23+
label: 'Trigger Loading',
24+
value: 'loading',
25+
icon: <LoadingIcon />,
26+
disabled: !boundaries.loading,
27+
},
28+
{
29+
label: 'Trigger Error',
30+
value: 'error',
31+
icon: <ErrorIcon />,
32+
disabled: !boundaries.error,
33+
},
34+
{
35+
label: 'Trigger Not Found',
36+
value: 'not-found',
37+
icon: <NotFoundIcon />,
38+
disabled: !boundaries['not-found'],
39+
},
2340
]
2441

2542
const resetOption = {
@@ -74,6 +91,7 @@ export function SegmentBoundaryTrigger({
7491
key={option.value}
7592
className="segment-boundary-dropdown-item"
7693
onClick={() => handleSelect(option.value)}
94+
disabled={option.disabled}
7795
>
7896
{option.icon}
7997
{option.label}
@@ -274,9 +292,14 @@ export const styles = `
274292
width: 100%;
275293
}
276294
295+
.segment-boundary-dropdown-item[data-disabled] {
296+
color: var(--color-gray-400);
297+
cursor: not-allowed;
298+
}
299+
277300
.segment-boundary-dropdown-item svg {
278301
margin-right: 12px;
279-
color: var(--color-gray-900);
302+
color: currentColor;
280303
}
281304
282305
.segment-boundary-dropdown-item:hover {

packages/next/src/next-devtools/dev-overlay/components/overview/segment-explorer.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ import {
1010
} from './segment-boundary-trigger'
1111
import { Tooltip } from '../../../components/tooltip'
1212
import { useRef, useState } from 'react'
13-
14-
const BUILTIN_PREFIX = '__next_builtin__'
13+
import {
14+
BUILTIN_PREFIX,
15+
getBoundaryOriginFileType,
16+
isBoundaryFile,
17+
normalizeBoundaryFilename,
18+
} from '../../../../server/app-render/segment-explorer-path'
1519

1620
const isFileNode = (node: SegmentTrieNode) => {
1721
return !!node.value?.type && !!node.value?.pagePath
@@ -133,6 +137,24 @@ function PageSegmentTreeLayerPresentation({
133137
}
134138

135139
const hasFilesChildren = filesChildrenKeys.length > 0
140+
const boundaries: Record<'not-found' | 'loading' | 'error', string | null> = {
141+
'not-found': null,
142+
loading: null,
143+
error: null,
144+
}
145+
146+
filesChildrenKeys.forEach((childKey) => {
147+
const childNode = node.children[childKey]
148+
if (!childNode || !childNode.value) return
149+
if (isBoundaryFile(childNode.value.type)) {
150+
const boundaryType = getBoundaryOriginFileType(childNode.value.type)
151+
152+
if (boundaryType in boundaries) {
153+
boundaries[boundaryType as keyof typeof boundaries] =
154+
childNode.value.pagePath || null
155+
}
156+
}
157+
})
136158

137159
return (
138160
<>
@@ -165,10 +187,15 @@ function PageSegmentTreeLayerPresentation({
165187
if (!childNode || !childNode.value) {
166188
return null
167189
}
190+
// If it's boundary node, which marks the existence of the boundary not the rendered status,
191+
// we don't need to present in the rendered files.
192+
if (isBoundaryFile(childNode.value.type)) {
193+
return null
194+
}
168195
const filePath = childNode.value.pagePath
169196
const lastSegment = filePath.split('/').pop() || ''
170197
const isBuiltin = filePath.startsWith(BUILTIN_PREFIX)
171-
const fileName = lastSegment.replace(BUILTIN_PREFIX, '')
198+
const fileName = normalizeBoundaryFilename(lastSegment)
172199

173200
return (
174201
<span
@@ -208,6 +235,7 @@ function PageSegmentTreeLayerPresentation({
208235
<SegmentBoundaryTrigger
209236
offset={6}
210237
onSelectBoundary={pageChild.value.setBoundaryType}
238+
boundaries={boundaries}
211239
/>
212240
)}
213241
</div>

packages/next/src/next-devtools/dev-overlay/segment-explorer-trie.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function createTrie<Value = string>({
9797
function remove(value: Value) {
9898
let currentNode = root
9999
const segments = getCharacters(value)
100+
100101
const stack: TrieNode<Value>[] = []
101102
let found = true
102103
for (const segment of segments) {

packages/next/src/next-devtools/userspace/app/segment-explorer-node.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,14 @@ function SegmentTrieNode({
3131
pagePath: string
3232
}): React.ReactNode {
3333
const { boundaryType, setBoundaryType } = useSegmentState()
34-
const nodeState: SegmentNodeState = useMemo(
35-
() => ({
34+
const nodeState: SegmentNodeState = useMemo(() => {
35+
return {
3636
type,
3737
pagePath,
3838
boundaryType,
3939
setBoundaryType,
40-
}),
41-
[type, pagePath, boundaryType, setBoundaryType]
42-
)
40+
}
41+
}, [type, pagePath, boundaryType, setBoundaryType])
4342

4443
// Use `useLayoutEffect` to ensure the state is updated during suspense.
4544
// `useEffect` won't work as the state is preserved during suspense.
@@ -143,7 +142,10 @@ export function SegmentStateProvider({ children }: { children: ReactNode }) {
143142
return (
144143
<SegmentStateContext.Provider
145144
key={errorBoundaryKey}
146-
value={{ boundaryType, setBoundaryType: setBoundaryTypeAndReload }}
145+
value={{
146+
boundaryType,
147+
setBoundaryType: setBoundaryTypeAndReload,
148+
}}
147149
>
148150
{children}
149151
</SegmentStateContext.Provider>

packages/next/src/server/app-render/create-component-tree.tsx

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import type {
2626
UseCachePageComponentProps,
2727
} from '../use-cache/use-cache-wrapper'
2828
import { DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment'
29-
import { getConventionPathByType } from './segment-explorer-path'
29+
import {
30+
BOUNDARY_PREFIX,
31+
getConventionPathByType,
32+
} from './segment-explorer-path'
3033

3134
/**
3235
* Use the provided loader tree to create the React Component tree.
@@ -410,23 +413,24 @@ async function createComponentTreeInternal({
410413
<MetadataOutlet ready={getMetadataReady} />
411414
)
412415

413-
const notFoundElement = await createBoundaryConventionElement({
414-
ctx,
415-
conventionName: 'not-found',
416-
Component: NotFound,
417-
styles: notFoundStyles,
418-
tree,
419-
})
416+
const [notFoundElement, notFoundFilePath] =
417+
await createBoundaryConventionElement({
418+
ctx,
419+
conventionName: 'not-found',
420+
Component: NotFound,
421+
styles: notFoundStyles,
422+
tree,
423+
})
420424

421-
const forbiddenElement = await createBoundaryConventionElement({
425+
const [forbiddenElement] = await createBoundaryConventionElement({
422426
ctx,
423427
conventionName: 'forbidden',
424428
Component: Forbidden,
425429
styles: forbiddenStyles,
426430
tree,
427431
})
428432

429-
const unauthorizedElement = await createBoundaryConventionElement({
433+
const [unauthorizedElement] = await createBoundaryConventionElement({
430434
ctx,
431435
conventionName: 'unauthorized',
432436
Component: Unauthorized,
@@ -546,8 +550,8 @@ async function createComponentTreeInternal({
546550
)
547551

548552
const templateFilePath = getConventionPathByType(tree, dir, 'template')
549-
550553
const errorFilePath = getConventionPathByType(tree, dir, 'error')
554+
const loadingFilePath = getConventionPathByType(tree, dir, 'loading')
551555

552556
const wrappedErrorStyles =
553557
isSegmentViewEnabled && errorFilePath ? (
@@ -558,6 +562,34 @@ async function createComponentTreeInternal({
558562
errorStyles
559563
)
560564

565+
// Add a suffix to avoid conflict with the segment view node representing rendered file.
566+
// existence: not-found.tsx@boundary
567+
// rendered: not-found.tsx
568+
const fileNameSuffix = '@boundary'
569+
const segmentViewBoundaries = isSegmentViewEnabled ? (
570+
<>
571+
{notFoundFilePath && (
572+
<SegmentViewNode
573+
type={`${BOUNDARY_PREFIX}not-found`}
574+
pagePath={notFoundFilePath + fileNameSuffix}
575+
/>
576+
)}
577+
{loadingFilePath && (
578+
<SegmentViewNode
579+
type={`${BOUNDARY_PREFIX}loading`}
580+
pagePath={loadingFilePath + fileNameSuffix}
581+
/>
582+
)}
583+
{errorFilePath && (
584+
<SegmentViewNode
585+
type={`${BOUNDARY_PREFIX}error`}
586+
pagePath={errorFilePath + fileNameSuffix}
587+
/>
588+
)}
589+
{/* do not surface forbidden and unauthorized boundaries yet as they're unstable */}
590+
</>
591+
) : null
592+
561593
return [
562594
parallelRouteKey,
563595
<LayoutRouter
@@ -581,6 +613,7 @@ async function createComponentTreeInternal({
581613
notFound={notFoundComponent}
582614
forbidden={forbiddenComponent}
583615
unauthorized={unauthorizedComponent}
616+
{...(isSegmentViewEnabled && { segmentViewBoundaries })}
584617
// Since gracefullyDegrade only applies to bots, only
585618
// pass it when we're in a bot context to avoid extra bytes.
586619
{...(gracefullyDegrade && { gracefullyDegrade })}
@@ -603,8 +636,8 @@ async function createComponentTreeInternal({
603636
}
604637

605638
let loadingElement = Loading ? <Loading key="l" /> : null
639+
const loadingFilePath = getConventionPathByType(tree, dir, 'loading')
606640
if (isSegmentViewEnabled && loadingElement) {
607-
const loadingFilePath = getConventionPathByType(tree, dir, 'loading')
608641
if (loadingFilePath) {
609642
loadingElement = (
610643
<SegmentViewNode
@@ -1098,18 +1131,20 @@ async function createBoundaryConventionElement({
10981131
</>
10991132
) : undefined
11001133

1134+
const pagePath = getConventionPathByType(tree, dir, conventionName)
1135+
11011136
const wrappedElement =
11021137
isSegmentViewEnabled && element ? (
11031138
<SegmentViewNode
11041139
key={cacheNodeKey + '-' + conventionName}
11051140
type={conventionName}
1106-
pagePath={getConventionPathByType(tree, dir, conventionName)!}
1141+
pagePath={pagePath!}
11071142
>
11081143
{element}
11091144
</SegmentViewNode>
11101145
) : (
11111146
element
11121147
)
11131148

1114-
return wrappedElement
1149+
return [wrappedElement, pagePath] as const
11151150
}

packages/next/src/server/app-render/segment-explorer-path.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { LoaderTree } from '../lib/app-dir-module'
22

3+
export const BUILTIN_PREFIX = '__next_builtin__'
4+
35
export function normalizeConventionFilePath(
46
projectDir: string,
57
conventionPath: string | undefined
@@ -22,12 +24,28 @@ export function normalizeConventionFilePath(
2224
if (nextInternalPrefixRegex.test(relativePath)) {
2325
relativePath = relativePath.replace(nextInternalPrefixRegex, '')
2426
// Add a special prefix to let segment explorer know it's a built-in component
25-
relativePath = `__next_builtin__${relativePath}`
27+
relativePath = `${BUILTIN_PREFIX}${relativePath}`
2628
}
2729

2830
return relativePath
2931
}
3032

33+
export const BOUNDARY_SUFFIX = '@boundary'
34+
export function normalizeBoundaryFilename(filename: string) {
35+
return filename
36+
.replace(new RegExp(`^${BUILTIN_PREFIX}`), '')
37+
.replace(new RegExp(`${BOUNDARY_SUFFIX}$`), '')
38+
}
39+
40+
export const BOUNDARY_PREFIX = 'boundary:'
41+
export function isBoundaryFile(fileType: string) {
42+
return fileType.startsWith(BOUNDARY_PREFIX)
43+
}
44+
45+
export function getBoundaryOriginFileType(fileType: string) {
46+
return fileType.replace(BOUNDARY_PREFIX, '')
47+
}
48+
3149
export function getConventionPathByType(
3250
tree: LoaderTree,
3351
dir: string,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function NotFound() {
2+
return <div>Not Found @foo</div>
3+
}

0 commit comments

Comments
 (0)