Skip to content

Commit 0d6f0ae

Browse files
committed
feat: implement inline NotFound handling for unknown routes and slugs across multiple pages
1 parent 286fa1d commit 0d6f0ae

8 files changed

Lines changed: 176 additions & 50 deletions

File tree

srcs/Preflight.App/App.razor

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
1-
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
1+
@*
2+
Why <NotFound> template instead of NotFoundPage="typeof(Pages.NotFound)":
3+
NotFoundPage navigates the URL to the page's @page route ("/404"),
4+
which on .NET 10 preview WASM standalone has surfaced edge cases
5+
where unmatched paths (e.g. /wizard/404) silently fall through with
6+
no render at all. The legacy <NotFound> template renders the page
7+
INLINE without changing the URL - bulletproof since Blazor 3.x and
8+
preserves the original URL so users can fix the address bar or hit
9+
back cleanly. LayoutView wraps the page in MainLayout (same chrome
10+
as every other route), since the @page directive on Pages.NotFound
11+
only applies when the router enters via that route, not via the
12+
template fallback.
13+
*@
14+
<Router AppAssembly="@typeof(App).Assembly">
215
<Found Context="routeData">
316
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
417
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
518
</Found>
19+
<NotFound>
20+
<LayoutView Layout="@typeof(MainLayout)">
21+
@* Inside the Router's NotFound template body - Razor resolves
22+
<NotFound /> to the Preflight.App.Pages.NotFound component
23+
(imported via _Imports.razor), not back to the template
24+
parameter, because we're inside a child element scope. *@
25+
<NotFound />
26+
</LayoutView>
27+
</NotFound>
628
</Router>

srcs/Preflight.App/Layout/Breadcrumbs.razor

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@* Compact breadcrumbs shown in the header between the logo and switchers.
22
Computed from NavigationManager.Uri on every location change. *@
33
@implements IDisposable
4+
@using Preflight.App.Content
45
@inject NavigationManager Nav
56
@inject IStringLocalizer<SharedResources> L
67

@@ -62,13 +63,19 @@
6263
if (sub == "guided")
6364
{
6465
_crumbs.Add(new Crumb(L["Wizard.Guided.Title"], "wizard/guided/1"));
65-
if (segments.Length >= 3 && int.TryParse(segments[2], out var step))
66+
// Only emit a step crumb if the number is in range. The page
67+
// itself shows <NotFound /> for out-of-range steps; carrying
68+
// a "Step 999" crumb in the header on a 404 view is just noise.
69+
if (segments.Length >= 3
70+
&& int.TryParse(segments[2], out var step)
71+
&& step >= 1 && step <= GuidedWizardTotalSteps)
6672
{
6773
_crumbs.Add(new Crumb($"{L["Common.Step"]} {step}", null));
6874
}
6975
}
7076
else if (sub == "presets") _crumbs.Add(new Crumb(L["Wizard.Presets.Title"], null));
7177
else if (sub == "review") _crumbs.Add(new Crumb(L["Wizard.Review.Title"], null));
78+
// Any other /wizard/{x} is a 404 - no extra crumb.
7279
}
7380
break;
7481

@@ -77,7 +84,13 @@
7784
if (segments.Length >= 2)
7885
{
7986
var section = segments[1].ToLowerInvariant();
80-
_crumbs.Add(new Crumb(L[$"Advanced.Section.{section}"], null));
87+
// Only render the section crumb when it's a known id; otherwise
88+
// IStringLocalizer falls back to echoing the raw key
89+
// ("Advanced.Section.test"), which reads as a leak in the header.
90+
if (SectionRegistry.AllSectionIds.Contains(section, StringComparer.Ordinal))
91+
{
92+
_crumbs.Add(new Crumb(L[$"Advanced.Section.{section}"], null));
93+
}
8194
}
8295
break;
8396

@@ -86,11 +99,23 @@
8699
if (segments.Length >= 2)
87100
{
88101
var topic = segments[1].ToLowerInvariant();
89-
_crumbs.Add(new Crumb(L[$"Advanced.Section.{topic}"], null));
102+
// Same guard as /advanced/{x} - unknown slugs render <NotFound />,
103+
// breadcrumb stops at "Documentation" instead of leaking the key.
104+
if (SectionRegistry.AllSectionIds.Contains(topic, StringComparer.Ordinal))
105+
{
106+
_crumbs.Add(new Crumb(L[$"Advanced.Section.{topic}"], null));
107+
}
90108
}
91109
break;
110+
111+
// Anything else under root (/foo, /xyz) is a 404 → render no breadcrumbs.
112+
// The Router's <NotFound> template handles the body; the header stays clean.
92113
}
93114
}
94115

116+
// Keep in sync with GuidedWizard.TotalSteps. Lifted here so Breadcrumbs
117+
// doesn't need a reference to that page type just for one constant.
118+
private const int GuidedWizardTotalSteps = 5;
119+
95120
private sealed record Crumb(string Label, string? Href);
96121
}

srcs/Preflight.App/Pages/Advanced/AdvancedShell.razor

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,22 @@
99
@inject UnattendXmlBuilder XmlBuilder
1010
@inject IJSRuntime JS
1111

12-
<PageTitle>@L["Advanced.Title"] · preflight.xml</PageTitle>
12+
@*
13+
Unknown {Section} → render the global NotFound page inline. The route
14+
matches any string for the catch-all parameter, so the Router's
15+
<NotFound> template doesn't fire here. URL is preserved (user can
16+
fix the typo without losing history). When Section is empty (just
17+
/advanced) we fall through to the normal default ("region").
18+
*@
19+
@if (!IsKnownSection)
20+
{
21+
<NotFound />
22+
}
23+
else
24+
{
25+
<PageTitle>@L["Advanced.Title"] · preflight.xml</PageTitle>
1326

14-
<div class="pf-advanced-shell" style="padding: 24px 16px; max-width: 1600px; margin: 0 auto;">
27+
<div class="pf-advanced-shell" style="padding: 24px 16px; max-width: 1600px; margin: 0 auto;">
1528
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" VerticalAlignment="VerticalAlignment.Center">
1629
<FluentLabel Typo="Typography.PageTitle">⚙️ @L["Advanced.Title"]</FluentLabel>
1730
<FluentSpacer />
@@ -174,6 +187,7 @@
174187
</div>
175188
</div>
176189
}
190+
}
177191

178192
@code {
179193
[Parameter] public string? Section { get; set; }
@@ -182,6 +196,13 @@
182196
// nav menu and the route-dispatch switch pick them up automatically.
183197
private static readonly IReadOnlyList<string> _sections = SectionRegistry.AllSectionIds;
184198

199+
// True when no Section was specified (default landing on /advanced shows
200+
// the first section), or when the supplied Section is a real registered
201+
// id. Anything else is a 404 - see the @if guard at the top of markup.
202+
private bool IsKnownSection =>
203+
string.IsNullOrWhiteSpace(Section) ||
204+
_sections.Contains(Section, StringComparer.Ordinal);
205+
185206
// Groups the user has explicitly expanded in addition to the one that owns the
186207
// active section (that one is always open).
187208
private readonly HashSet<string> _openGroupIds = new(StringComparer.Ordinal);

srcs/Preflight.App/Pages/Docs/DocsEntry.razor

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,22 @@
55
@inject IStringLocalizer<SharedResources> L
66
@inject HttpClient Http
77

8-
<PageTitle>@_name · @L["Docs.Title"]</PageTitle>
8+
@*
9+
Unknown slug → show the global NotFound page inline. The route MATCHES
10+
the {Slug} catch-all, so the Router's <NotFound> template doesn't fire
11+
here - we have to guard it ourselves. Inline-render preserves the
12+
original URL so the user can correct a typo, and reuses the same
13+
Pages.NotFound component for visual consistency with /wizard/404 etc.
14+
*@
15+
@if (!IsKnownSection)
16+
{
17+
<NotFound />
18+
}
19+
else
20+
{
21+
<PageTitle>@_name · @L["Docs.Title"]</PageTitle>
922

10-
<div class="pf-docs-entry" style="padding: 16px; max-width: 1400px; margin: 0 auto;">
23+
<div class="pf-docs-entry" style="padding: 16px; max-width: 1400px; margin: 0 auto;">
1124
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" VerticalAlignment="VerticalAlignment.Center">
1225
<FluentAnchor Href="docs" Appearance="Appearance.Outline">← @L["Docs.BackToIndex"]</FluentAnchor>
1326
@if (IsKnownSection)
@@ -115,7 +128,8 @@
115128
}
116129
</FluentGridItem>
117130
</FluentGrid>
118-
</div>
131+
</div>
132+
}
119133

120134
@code {
121135
[Parameter] public string Slug { get; set; } = "";

srcs/Preflight.App/Pages/Landing.razor

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,14 @@
7272
private string? _importMessage;
7373
private MessageIntent _importIntent = MessageIntent.Info;
7474

75-
private void GoToWizard() => GoTo(AppMode.Wizard, "/wizard");
76-
private void GoToAdvanced() => GoTo(AppMode.Advanced, "/advanced");
77-
private void GoToDocs() => GoTo(AppMode.Docs, "/docs");
75+
// Paths are passed straight to Nav.NavigateTo, which resolves them
76+
// against <base href>. They MUST be base-relative (no leading slash) -
77+
// on a subpath deploy (e.g. /preflight.xml/) URI-merge with a leading
78+
// slash drops the base path and the browser jumps to the host root.
79+
// Same rule as in CommandPalette.BuildCommands().
80+
private void GoToWizard() => GoTo(AppMode.Wizard, "wizard");
81+
private void GoToAdvanced() => GoTo(AppMode.Advanced, "advanced");
82+
private void GoToDocs() => GoTo(AppMode.Docs, "docs");
7883

7984
private void GoTo(AppMode mode, string path)
8085
{

srcs/Preflight.App/Pages/Wizard/GuidedWizard.razor

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,21 @@
33
@inject NavigationManager Nav
44
@inject ModeService Mode
55

6-
<PageTitle>@L["Wizard.Guided.Title"] · Step @Step</PageTitle>
6+
@*
7+
Out-of-range Step (e.g. /wizard/guided/0 or /wizard/guided/999) → render
8+
the global NotFound page inline. The :int constraint already filters
9+
non-numeric values so the Router's <NotFound> handles those, but the
10+
range check has to live here.
11+
*@
12+
@if (Step < 1 || Step > TotalSteps)
13+
{
14+
<NotFound />
15+
}
16+
else
17+
{
18+
<PageTitle>@L["Wizard.Guided.Title"] · Step @Step</PageTitle>
719

8-
<FluentStack Orientation="Orientation.Vertical" HorizontalGap="24" Style="padding: 48px 16px; max-width: 1080px; margin: 0 auto;">
20+
<FluentStack Orientation="Orientation.Vertical" HorizontalGap="24" Style="padding: 48px 16px; max-width: 1080px; margin: 0 auto;">
921

1022
@*
1123
Step indicator - "Step N of M" label on the left, a plane-on-runway
@@ -59,9 +71,8 @@
5971
case 5:
6072
<Step_Finish />
6173
break;
62-
default:
63-
<FluentMessageBar Intent="MessageIntent.Error">Unknown step.</FluentMessageBar>
64-
break;
74+
// No default needed - the @if guard at the top of the component
75+
// already redirects out-of-range Step values to <NotFound />.
6576
}
6677

6778
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="8">
@@ -82,7 +93,8 @@
8293
}
8394
</FluentStack>
8495

85-
</FluentStack>
96+
</FluentStack>
97+
}
8698

8799
@code {
88100
private const int TotalSteps = 5;

srcs/Preflight.App/Preflight.App.csproj

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,37 @@
3232
</ItemGroup>
3333

3434
<!--
35-
🔒 IL Trimmer guard for the vendored UnattendGenerator.
35+
🔒 IL Trimmer guards for Newtonsoft-driven JSON deserialization.
3636
3737
Blazor WASM Release publishing trims IL aggressively (default for net8+).
38-
UnattendGenerator deserializes Bloatware.json via Newtonsoft `$type`
39-
polymorphism - concrete types are referenced ONLY by string names inside
40-
the JSON catalog, so the trimmer can't see those references and strips
41-
them. At runtime Newtonsoft fails with
42-
"Could not load type 'Schneegans.Unattend.PackageBloatwareStep, UnattendGenerator'"
43-
which surfaces as Blazor's "An unhandled error has occurred" UI.
44-
45-
Pinning the assembly as a TrimmerRootAssembly preserves every public
46-
type inside it. Adds ~150 KB to the published WASM bundle; without this
47-
the app crashes on boot the first time anything resolves BloatwareCatalog.
38+
Three things in our JSON path are reflection-only and get stripped without
39+
explicit roots, each surfacing as a different startup crash:
40+
41+
1. UnattendGenerator (vendored Schneegans assembly) - Bloatware.json uses
42+
Newtonsoft `$type` polymorphism, so concrete BloatwareStep subclasses
43+
are referenced only as strings. Without this root:
44+
"Could not load type 'Schneegans.Unattend.PackageBloatwareStep,
45+
UnattendGenerator'"
46+
47+
2. System.Collections.Immutable - the catalog deserializes into
48+
ImmutableList<BloatwareStep>. Newtonsoft uses ImmutableList.CreateRange
49+
via reflection; nothing else calls it directly, so the trimmer drops
50+
it. Without this root:
51+
"Unable to find a constructor to use for type
52+
System.Collections.Immutable.ImmutableList`1[BloatwareStep]"
53+
54+
3. Newtonsoft.Json itself - several reflection paths (ImmutableCollections
55+
utils, $type resolver) are reachable only through internals; safest to
56+
pin the whole assembly.
57+
58+
Total cost ~400 KB on top of the published WASM bundle. Without these the
59+
app crashes on first construction of UnattendXmlBuilder (singleton -> first
60+
page that touches the bloatware catalog).
4861
-->
4962
<ItemGroup>
5063
<TrimmerRootAssembly Include="UnattendGenerator" />
64+
<TrimmerRootAssembly Include="System.Collections.Immutable" />
65+
<TrimmerRootAssembly Include="Newtonsoft.Json" />
5166
</ItemGroup>
5267

5368
</Project>

0 commit comments

Comments
 (0)