@lovrozagar/flare 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -19
- package/package.json +10 -7
- package/src/broadcast/index.ts +1 -1
- package/src/broadcast/provider.tsx +2 -2
- package/src/caches/index.ts +26 -0
- package/src/components/await.tsx +36 -39
- package/src/components/dev-error-overlay.tsx +8 -9
- package/src/components/direction-script.tsx +2 -2
- package/src/components/locale-script.tsx +2 -2
- package/src/components/reset-css.tsx +1 -1
- package/src/components/ssr-context.tsx +7 -14
- package/src/components/theme-script.tsx +2 -2
- package/src/components/view-transition-css.tsx +1 -1
- package/src/defer/index.ts +22 -0
- package/src/direction/index.tsx +61 -36
- package/src/errors/index.ts +45 -11
- package/src/fonts/component.tsx +2 -1
- package/src/form/index.tsx +18 -12
- package/src/generators/index.ts +73 -30
- package/src/head-client/index.ts +7 -0
- package/src/history/index.ts +6 -0
- package/src/hydrate/index.tsx +90 -60
- package/src/hydration/index.ts +26 -6
- package/src/i18n/index.ts +11 -2
- package/src/image/index.tsx +7 -4
- package/src/intercept-outlet/index.tsx +6 -2
- package/src/lazy/index.tsx +50 -50
- package/src/link/index.tsx +97 -58
- package/src/loader-pipeline/index.ts +93 -35
- package/src/locale/cookie.ts +15 -0
- package/src/locale/index.tsx +24 -35
- package/src/locale.ts +1 -0
- package/src/middleware/builtins/api-proxy.ts +58 -4
- package/src/middleware/builtins/cdn-proxy.ts +24 -3
- package/src/middleware/builtins/i18n.ts +23 -25
- package/src/middleware/builtins/keepalive.ts +2 -3
- package/src/mount/index.ts +8 -1
- package/src/navigation/index.ts +575 -129
- package/src/navigation/types.ts +11 -1
- package/src/navigation-progress/index.tsx +17 -16
- package/src/ndjson-client/index.ts +42 -17
- package/src/ndjson-server/index.ts +78 -29
- package/src/outlet/index.tsx +192 -97
- package/src/outlet/types.ts +9 -1
- package/src/plugins/dev-cdn-cache.ts +18 -5
- package/src/plugins/dev-dashboard/client.tsx +130 -118
- package/src/plugins/dev-prerender.ts +6 -1
- package/src/plugins/dev-server.ts +12 -3
- package/src/plugins/image-plugin.ts +18 -4
- package/src/plugins/index.ts +10 -7
- package/src/plugins/prerender-plugin.ts +3 -3
- package/src/plugins/server-fn.ts +15 -20
- package/src/plugins/solid-jsx.d.ts +3 -3
- package/src/plugins/sx-ast/atomic.ts +5 -5
- package/src/plugins/sx-ast/index.ts +9 -0
- package/src/plugins/sx.d.ts +4 -5
- package/src/plugins/virtual.ts +11 -6
- package/src/prerender/index.ts +68 -32
- package/src/protocol.ts +65 -0
- package/src/query-client/index.tsx +73 -14
- package/src/rewrite/index.ts +10 -4
- package/src/route-builder/create-layout.ts +1 -1
- package/src/route-builder/create-root-layout.ts +1 -1
- package/src/route-builder/types.ts +5 -1
- package/src/router-primitives/types.ts +2 -0
- package/src/security/index.ts +41 -5
- package/src/server/index.ts +1 -2
- package/src/server-fn/get-input.ts +90 -0
- package/src/server-fn/http-error.ts +13 -0
- package/src/server-fn/index.ts +94 -70
- package/src/server-fn-query.ts +9 -17
- package/src/server-handler/etag.ts +4 -2
- package/src/server-handler/headers.ts +1 -2
- package/src/server-handler/index.ts +164 -56
- package/src/server-handler/types.ts +2 -0
- package/src/service-worker/template.ts +10 -42
- package/src/ssr/head-prefix.ts +2 -1
- package/src/ssr/hoist-head-markers.ts +25 -0
- package/src/ssr/index.tsx +109 -92
- package/src/state-parser/index.ts +38 -26
- package/src/styles/index.ts +89 -60
- package/src/testing/index.ts +8 -3
- package/src/theme/index.tsx +35 -32
- package/src/tracing/timing.ts +6 -1
- package/src/url/index.ts +9 -0
package/src/outlet/index.tsx
CHANGED
|
@@ -4,14 +4,15 @@ import {
|
|
|
4
4
|
createEffect,
|
|
5
5
|
createMemo,
|
|
6
6
|
createSignal,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Errored,
|
|
8
|
+
Loading,
|
|
9
9
|
onCleanup,
|
|
10
|
-
|
|
10
|
+
onSettled,
|
|
11
11
|
Show,
|
|
12
|
-
|
|
12
|
+
untrack,
|
|
13
13
|
useContext,
|
|
14
14
|
} from "solid-js";
|
|
15
|
+
import type { JSX } from "@solidjs/web";
|
|
15
16
|
import type { GlobalBoundaries } from "../boundaries/index.ts";
|
|
16
17
|
import type { ChannelMessage, SerializedInvalidateOptions } from "../broadcast/channel.ts";
|
|
17
18
|
import { BroadcastCtx } from "../broadcast/context.ts";
|
|
@@ -56,8 +57,8 @@ export type {
|
|
|
56
57
|
ViewTransitionOptions,
|
|
57
58
|
} from "./types.ts";
|
|
58
59
|
|
|
59
|
-
/**
|
|
60
|
-
export const RouterContext = createContext<FlareProviderContext>();
|
|
60
|
+
/** Default `null` (not `undefined`) — Solid 2 throws ContextNotFoundError when default is undefined. */
|
|
61
|
+
export const RouterContext = createContext<FlareProviderContext | null>(null);
|
|
61
62
|
export const DepthContext = createContext<number>(0);
|
|
62
63
|
|
|
63
64
|
export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
@@ -66,10 +67,11 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
66
67
|
const [navigationPhase, setNavigationPhase] = createSignal<NavigationPhase>("idle");
|
|
67
68
|
const isNavigating = createMemo(() => navigationPhase() !== "idle");
|
|
68
69
|
const [viewTransitionSignal, setViewTransition] = createSignal<BrowserViewTransition | null>(null);
|
|
69
|
-
const [matches, setMatches] = createSignal<ClientMatch[]>(props.matches);
|
|
70
|
+
const [matches, setMatches] = createSignal<ClientMatch[]>(untrack(() => props.matches));
|
|
71
|
+
const [matchesEpoch, setMatchesEpoch] = createSignal(0);
|
|
70
72
|
const [notFound, setNotFound] = createSignal(false);
|
|
71
|
-
const [params, setParams] = createSignal<Record<string, string | string[]>>(props.params);
|
|
72
|
-
const [search, setSearch] = createSignal<SearchParams>(props.search ?? {});
|
|
73
|
+
const [params, setParams] = createSignal<Record<string, string | string[]>>(untrack(() => props.params));
|
|
74
|
+
const [search, setSearch] = createSignal<SearchParams>(untrack(() => props.search) ?? {});
|
|
73
75
|
|
|
74
76
|
const location = createMemo(() => {
|
|
75
77
|
const allMatches = matches();
|
|
@@ -98,15 +100,27 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
98
100
|
/* Stub navigate/prefetch — replaced by setupNavigation */
|
|
99
101
|
let navigateFn: (options: InternalNavigateOptions) => Promise<void> = () => Promise.resolve();
|
|
100
102
|
let prefetchFn: (options: {
|
|
103
|
+
modulesOnly?: boolean;
|
|
101
104
|
params?: Record<string, unknown>;
|
|
102
105
|
search?: Record<string, unknown>;
|
|
103
106
|
to: string;
|
|
104
107
|
}) => Promise<void> = () => Promise.resolve();
|
|
105
108
|
|
|
106
109
|
const channel = useContext(BroadcastCtx);
|
|
110
|
+
const staticProps = untrack(() => ({
|
|
111
|
+
boundaries: props.boundaries,
|
|
112
|
+
caseSensitive: props.caseSensitive,
|
|
113
|
+
layouts: props.layouts,
|
|
114
|
+
localeConfig: props.localeConfig,
|
|
115
|
+
matchCache: props.matchCache,
|
|
116
|
+
prefetchCache: props.prefetchCache,
|
|
117
|
+
resolvers: props.resolvers,
|
|
118
|
+
routeTree: props.routeTree,
|
|
119
|
+
routerCacheDefaults: props.routerCacheDefaults,
|
|
120
|
+
}));
|
|
107
121
|
|
|
108
122
|
function invalidate(options?: Parameters<FlareProviderContext["invalidate"]>[0]): void {
|
|
109
|
-
|
|
123
|
+
staticProps.matchCache.invalidate(options);
|
|
110
124
|
navigateFn({
|
|
111
125
|
replace: true,
|
|
112
126
|
revalidate: true,
|
|
@@ -122,17 +136,18 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
const ctx: FlareProviderContext = {
|
|
125
|
-
boundaries:
|
|
126
|
-
caseSensitive:
|
|
139
|
+
boundaries: staticProps.boundaries,
|
|
140
|
+
caseSensitive: staticProps.caseSensitive,
|
|
127
141
|
hydrated,
|
|
128
142
|
intercepted,
|
|
129
143
|
invalidate,
|
|
130
144
|
isNavigating,
|
|
131
|
-
layouts:
|
|
132
|
-
localeConfig:
|
|
145
|
+
layouts: staticProps.layouts,
|
|
146
|
+
localeConfig: staticProps.localeConfig,
|
|
133
147
|
location,
|
|
134
|
-
matchCache:
|
|
148
|
+
matchCache: staticProps.matchCache,
|
|
135
149
|
matches,
|
|
150
|
+
matchesEpoch,
|
|
136
151
|
navigate: (opts) => {
|
|
137
152
|
if (opts.broadcast) {
|
|
138
153
|
channel.broadcast({ replace: opts.replace, to: opts.to, type: "navigate" });
|
|
@@ -143,10 +158,10 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
143
158
|
notFound,
|
|
144
159
|
params,
|
|
145
160
|
prefetch: (opts) => prefetchFn(opts),
|
|
146
|
-
prefetchCache:
|
|
147
|
-
resolvers:
|
|
148
|
-
routeTree:
|
|
149
|
-
routerCacheDefaults:
|
|
161
|
+
prefetchCache: staticProps.prefetchCache,
|
|
162
|
+
resolvers: staticProps.resolvers,
|
|
163
|
+
routeTree: staticProps.routeTree,
|
|
164
|
+
routerCacheDefaults: staticProps.routerCacheDefaults,
|
|
150
165
|
search,
|
|
151
166
|
setHydrated,
|
|
152
167
|
setIntercepted,
|
|
@@ -156,6 +171,7 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
156
171
|
setParams,
|
|
157
172
|
setSearch,
|
|
158
173
|
setViewTransition,
|
|
174
|
+
touchMatches: () => setMatchesEpoch((n) => n + 1),
|
|
159
175
|
viewTransition: viewTransitionSignal,
|
|
160
176
|
};
|
|
161
177
|
|
|
@@ -185,7 +201,7 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
185
201
|
});
|
|
186
202
|
Object.defineProperty(window, "__flare_devtools_cache__", {
|
|
187
203
|
configurable: true,
|
|
188
|
-
get: () =>
|
|
204
|
+
get: () => staticProps.matchCache.getAll(),
|
|
189
205
|
});
|
|
190
206
|
Object.defineProperty(window, "__flare_devtools_actions__", {
|
|
191
207
|
configurable: true,
|
|
@@ -199,14 +215,14 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
199
215
|
});
|
|
200
216
|
setMatches(restored);
|
|
201
217
|
},
|
|
202
|
-
clearPrefetchCache: () =>
|
|
218
|
+
clearPrefetchCache: () => staticProps.prefetchCache.clear(),
|
|
203
219
|
getCacheStats: () => ({
|
|
204
|
-
entries:
|
|
220
|
+
entries: staticProps.matchCache.getAll().map((c) => ({
|
|
205
221
|
age: Date.now() - c.updatedAt,
|
|
206
222
|
matchId: c.matchId,
|
|
207
223
|
})),
|
|
208
|
-
matchCount:
|
|
209
|
-
prefetchCount:
|
|
224
|
+
matchCount: staticProps.matchCache.getAll().length,
|
|
225
|
+
prefetchCount: staticProps.prefetchCache.size(),
|
|
210
226
|
}),
|
|
211
227
|
invalidate: (opts?: Parameters<FlareProviderContext["invalidate"]>[0]) => invalidate(opts),
|
|
212
228
|
navigate: (opts: InternalNavigateOptions) => navigateFn(opts),
|
|
@@ -223,11 +239,11 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
/* Cross-tab broadcast: receive invalidate/navigate from other tabs */
|
|
226
|
-
|
|
242
|
+
onSettled(() => {
|
|
227
243
|
const unsubscribe = channel.onMessage((msg: ChannelMessage) => {
|
|
228
244
|
if (msg.type === "invalidate") {
|
|
229
|
-
|
|
230
|
-
|
|
245
|
+
staticProps.matchCache.invalidate(msg.options);
|
|
246
|
+
staticProps.prefetchCache.clear();
|
|
231
247
|
navigateFn({
|
|
232
248
|
replace: true,
|
|
233
249
|
revalidate: true,
|
|
@@ -240,16 +256,17 @@ export function FlareProvider(props: FlareProviderProps): JSX.Element {
|
|
|
240
256
|
});
|
|
241
257
|
}
|
|
242
258
|
});
|
|
243
|
-
|
|
259
|
+
return unsubscribe;
|
|
244
260
|
});
|
|
245
261
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
262
|
+
/*
|
|
263
|
+
* Must run during the provider's first execution — Solid 2 onSettled waits
|
|
264
|
+
* for hydration to finish, and hydrate() awaits this callback, so onSettled
|
|
265
|
+
* would deadlock and never set html[data-flare-hydrated].
|
|
266
|
+
*/
|
|
267
|
+
untrack(() => props.onContextReady?.(ctx));
|
|
251
268
|
|
|
252
|
-
return <RouterContext
|
|
269
|
+
return <RouterContext value={ctx}>{props.children}</RouterContext>;
|
|
253
270
|
}
|
|
254
271
|
|
|
255
272
|
export function useRouterContext(): FlareProviderContext {
|
|
@@ -259,6 +276,18 @@ export function useRouterContext(): FlareProviderContext {
|
|
|
259
276
|
return ctx;
|
|
260
277
|
}
|
|
261
278
|
|
|
279
|
+
/** Instant shells mutate match.loaderData in place; t() must track that refresh. */
|
|
280
|
+
function liveTranslator(data: Accessor<unknown>, locale: () => string | undefined) {
|
|
281
|
+
const translator = createMemo(() =>
|
|
282
|
+
createTranslator((data() ?? {}) as Record<string, Record<string, string>>, locale()),
|
|
283
|
+
);
|
|
284
|
+
const t = ((key: string, values?: Record<string, unknown>) => translator()(key as never, values)) as ReturnType<
|
|
285
|
+
typeof createTranslator
|
|
286
|
+
>;
|
|
287
|
+
t.rich = ((key, components, ...args) => translator().rich(key, components, ...args)) as typeof t.rich;
|
|
288
|
+
return t;
|
|
289
|
+
}
|
|
290
|
+
|
|
262
291
|
export function useRouter(): FlareRouter {
|
|
263
292
|
const ctx = useRouterContext();
|
|
264
293
|
|
|
@@ -318,8 +347,8 @@ export function useRouter(): FlareRouter {
|
|
|
318
347
|
const [blocked, setBlocked] = createSignal(false);
|
|
319
348
|
|
|
320
349
|
/* Register SPA blocker — navigate() checks this before proceeding */
|
|
321
|
-
setActiveBlocker(when, () => setBlocked(true));
|
|
322
|
-
onCleanup(
|
|
350
|
+
const unregister = setActiveBlocker(when, () => setBlocked(true));
|
|
351
|
+
onCleanup(unregister);
|
|
323
352
|
|
|
324
353
|
/* Also handle browser-level unload (tab close, external nav) */
|
|
325
354
|
const handler = (e: BeforeUnloadEvent) => {
|
|
@@ -347,12 +376,20 @@ export function useRouter(): FlareRouter {
|
|
|
347
376
|
},
|
|
348
377
|
useLoaderData: ((options: { from: string }) =>
|
|
349
378
|
createMemo(() => {
|
|
379
|
+
ctx.matchesEpoch?.();
|
|
380
|
+
const overlay = ctx.intercepted();
|
|
381
|
+
if (overlay && options.from === overlay.match.virtualPath) return overlay.match.loaderData;
|
|
350
382
|
const m = ctx.matches().find((match) => match.virtualPath === options.from);
|
|
351
383
|
return m?.loaderData;
|
|
352
384
|
})) as never,
|
|
353
385
|
useLoaderT: ((options: { from: string }) => {
|
|
354
386
|
const data = createMemo(() => {
|
|
355
|
-
|
|
387
|
+
ctx.matchesEpoch?.();
|
|
388
|
+
const overlay = ctx.intercepted();
|
|
389
|
+
const m =
|
|
390
|
+
overlay && options.from === overlay.match.virtualPath
|
|
391
|
+
? overlay.match
|
|
392
|
+
: ctx.matches().find((match) => match.virtualPath === options.from);
|
|
356
393
|
return (m?.loaderData as Record<string, unknown> | undefined)?.t;
|
|
357
394
|
});
|
|
358
395
|
const lc = ctx.localeConfig;
|
|
@@ -362,18 +399,40 @@ export function useRouter(): FlareRouter {
|
|
|
362
399
|
const val = p[lc.paramName ?? "locale"];
|
|
363
400
|
return (typeof val === "string" ? val : undefined) ?? lc.defaultLocale;
|
|
364
401
|
};
|
|
365
|
-
return
|
|
402
|
+
return liveTranslator(data, locale);
|
|
366
403
|
}) as never,
|
|
367
|
-
useMatch: (options) =>
|
|
368
|
-
|
|
404
|
+
useMatch: (options) =>
|
|
405
|
+
createMemo(() => {
|
|
406
|
+
ctx.matchesEpoch?.();
|
|
407
|
+
const overlay = ctx.intercepted();
|
|
408
|
+
if (overlay && options.from === overlay.match.virtualPath) return overlay.match;
|
|
409
|
+
return ctx.matches().find((match) => match.virtualPath === options.from);
|
|
410
|
+
}),
|
|
411
|
+
useParams: ((options: { from: string }) =>
|
|
412
|
+
createMemo(() => {
|
|
413
|
+
const overlay = ctx.intercepted();
|
|
414
|
+
if (overlay) {
|
|
415
|
+
if (options.from === overlay.match.virtualPath) return overlay.params;
|
|
416
|
+
return overlay.backgroundLocation.params;
|
|
417
|
+
}
|
|
418
|
+
return ctx.params();
|
|
419
|
+
})) as never,
|
|
369
420
|
usePreloaderContext: ((options: { from: string }) =>
|
|
370
421
|
createMemo(() => {
|
|
422
|
+
ctx.matchesEpoch?.();
|
|
423
|
+
const overlay = ctx.intercepted();
|
|
424
|
+
if (overlay && options.from === overlay.match.virtualPath) return overlay.match.preloaderContext;
|
|
371
425
|
const m = ctx.matches().find((match) => match.virtualPath === options.from);
|
|
372
426
|
return m?.preloaderContext;
|
|
373
427
|
})) as never,
|
|
374
428
|
usePreloaderT: ((options: { from: string }) => {
|
|
375
429
|
const data = createMemo(() => {
|
|
376
|
-
|
|
430
|
+
ctx.matchesEpoch?.();
|
|
431
|
+
const overlay = ctx.intercepted();
|
|
432
|
+
const m =
|
|
433
|
+
overlay && options.from === overlay.match.virtualPath
|
|
434
|
+
? overlay.match
|
|
435
|
+
: ctx.matches().find((match) => match.virtualPath === options.from);
|
|
377
436
|
return (m?.preloaderContext as Record<string, unknown> | undefined)?.t;
|
|
378
437
|
});
|
|
379
438
|
const lc = ctx.localeConfig;
|
|
@@ -383,9 +442,17 @@ export function useRouter(): FlareRouter {
|
|
|
383
442
|
const val = p[lc.paramName ?? "locale"];
|
|
384
443
|
return (typeof val === "string" ? val : undefined) ?? lc.defaultLocale;
|
|
385
444
|
};
|
|
386
|
-
return
|
|
445
|
+
return liveTranslator(data, locale);
|
|
387
446
|
}) as never,
|
|
388
|
-
useSearch: ((
|
|
447
|
+
useSearch: ((options: { from: string }) =>
|
|
448
|
+
createMemo(() => {
|
|
449
|
+
const overlay = ctx.intercepted();
|
|
450
|
+
if (overlay) {
|
|
451
|
+
if (options.from === overlay.match.virtualPath) return overlay.search;
|
|
452
|
+
return overlay.backgroundLocation.search;
|
|
453
|
+
}
|
|
454
|
+
return ctx.search();
|
|
455
|
+
})) as never,
|
|
389
456
|
viewTransition: ctx.viewTransition,
|
|
390
457
|
};
|
|
391
458
|
}
|
|
@@ -404,62 +471,86 @@ export function Outlet(props?: OutletProps): JSX.Element {
|
|
|
404
471
|
|
|
405
472
|
return (
|
|
406
473
|
<Show when={!ctx.notFound() || depth === 0}>
|
|
407
|
-
<DepthContext
|
|
474
|
+
<DepthContext value={depth + 1}>
|
|
408
475
|
<OutletContent depth={depth} fallback={props?.fallback} />
|
|
409
|
-
</DepthContext
|
|
476
|
+
</DepthContext>
|
|
410
477
|
</Show>
|
|
411
478
|
) as JSX.Element;
|
|
412
479
|
}
|
|
413
480
|
|
|
414
481
|
function OutletContent(props: { depth: number; fallback?: JSX.Element }): JSX.Element {
|
|
415
482
|
const ctx = useRouterContext();
|
|
416
|
-
const router = useRouter();
|
|
417
483
|
const match: Accessor<ClientMatch | undefined> = createMemo(() => ctx.matches()[props.depth]);
|
|
418
484
|
|
|
485
|
+
/*
|
|
486
|
+
* Solid 2 <Show> is unkeyed by default — the child is reused across truthy
|
|
487
|
+
* values. Instant shells reuse the same match object so local page signals
|
|
488
|
+
* survive; a new object (different route or error identity) must remount.
|
|
489
|
+
*/
|
|
419
490
|
return (
|
|
420
491
|
<Show
|
|
421
492
|
fallback={ctx.notFound() && props.depth === 0 ? resolveNotFoundBoundary(ctx, props.depth) : null}
|
|
493
|
+
keyed
|
|
422
494
|
when={match()}
|
|
423
495
|
>
|
|
424
|
-
{(m) => {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const renderProps = () => ({
|
|
429
|
-
children: isPage() ? undefined : <Outlet fallback={props.fallback} />,
|
|
430
|
-
loaderData: m().loaderData,
|
|
431
|
-
location: ctx.location(),
|
|
432
|
-
preloaderContext: m().preloaderContext,
|
|
433
|
-
router,
|
|
434
|
-
});
|
|
496
|
+
{(m) => <MatchOutlet depth={props.depth} fallback={props.fallback} match={m} />}
|
|
497
|
+
</Show>
|
|
498
|
+
) as JSX.Element;
|
|
499
|
+
}
|
|
435
500
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
501
|
+
function MatchOutlet(props: { depth: number; fallback?: JSX.Element; match: ClientMatch }): JSX.Element {
|
|
502
|
+
const ctx = useRouterContext();
|
|
503
|
+
const hasError = createMemo(() => {
|
|
504
|
+
ctx.matchesEpoch?.();
|
|
505
|
+
return props.match.error;
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/*
|
|
509
|
+
* Pipeline errors rendered via reactive Show — must react to SPA nav
|
|
510
|
+
* match updates. Static `if (current.error)` only works for SSR initial render.
|
|
511
|
+
*/
|
|
512
|
+
return (
|
|
513
|
+
<Show
|
|
514
|
+
fallback={
|
|
515
|
+
<ErrorBoundaryWrapper depth={props.depth} match={props.match}>
|
|
516
|
+
<Loading fallback={props.fallback ?? null}>
|
|
517
|
+
<RouteRender fallback={props.fallback} match={props.match} />
|
|
518
|
+
</Loading>
|
|
519
|
+
</ErrorBoundaryWrapper>
|
|
520
|
+
}
|
|
521
|
+
when={hasError()}
|
|
522
|
+
>
|
|
523
|
+
{renderMatchError(ctx, props.depth, hasError())}
|
|
459
524
|
</Show>
|
|
460
525
|
) as JSX.Element;
|
|
461
526
|
}
|
|
462
527
|
|
|
528
|
+
function RouteRender(props: { fallback?: JSX.Element; match: ClientMatch }): JSX.Element {
|
|
529
|
+
const ctx = useRouterContext();
|
|
530
|
+
const router = useRouter();
|
|
531
|
+
const loaderData = createMemo(() => {
|
|
532
|
+
ctx.matchesEpoch?.();
|
|
533
|
+
return props.match.loaderData;
|
|
534
|
+
});
|
|
535
|
+
const preloaderContext = createMemo(() => {
|
|
536
|
+
ctx.matchesEpoch?.();
|
|
537
|
+
return props.match.preloaderContext;
|
|
538
|
+
});
|
|
539
|
+
/* Capture once per MatchOutlet instance. Keyed <Show> remounts when the
|
|
540
|
+
* match identity changes; recreating <Outlet> on epoch would remount layouts. */
|
|
541
|
+
const nested = untrack(() => (props.match._type === "render" ? undefined : <Outlet fallback={props.fallback} />));
|
|
542
|
+
const Render = untrack(() => props.match.render);
|
|
543
|
+
return (
|
|
544
|
+
<Render
|
|
545
|
+
children={nested}
|
|
546
|
+
loaderData={loaderData()}
|
|
547
|
+
location={ctx.location()}
|
|
548
|
+
preloaderContext={preloaderContext()}
|
|
549
|
+
router={router}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
463
554
|
/**
|
|
464
555
|
* Re-run loaders for the current URL. Used by error boundaries
|
|
465
556
|
* to let users retry after transient failures.
|
|
@@ -491,7 +582,7 @@ function renderMatchError(ctx: FlareProviderContext, depth: number, error: unkno
|
|
|
491
582
|
if (error instanceof UnauthorizedError) {
|
|
492
583
|
return resolveUnauthorizedBoundary(ctx, depth, error, retry);
|
|
493
584
|
}
|
|
494
|
-
/* Pipeline errors: reset = retry (no
|
|
585
|
+
/* Pipeline errors: reset = retry (no Errored to clear, must re-run loaders) */
|
|
495
586
|
return resolveErrorBoundary(ctx, depth, error, retry, retry);
|
|
496
587
|
}
|
|
497
588
|
|
|
@@ -500,37 +591,41 @@ function ErrorBoundaryWrapper(props: { children: JSX.Element; depth: number; mat
|
|
|
500
591
|
let resetFn: (() => void) | undefined;
|
|
501
592
|
|
|
502
593
|
/*
|
|
503
|
-
* When match changes (SPA navigation), reset
|
|
594
|
+
* When match changes (SPA navigation), reset Errored so it exits
|
|
504
595
|
* fallback mode. Without this, navigating from error page → valid page
|
|
505
|
-
* stays stuck in
|
|
596
|
+
* stays stuck in Errored fallback.
|
|
506
597
|
*/
|
|
507
|
-
createEffect(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
598
|
+
createEffect(
|
|
599
|
+
() => {
|
|
600
|
+
void props.match.virtualPath;
|
|
601
|
+
void props.match.error;
|
|
602
|
+
},
|
|
603
|
+
() => {
|
|
604
|
+
resetFn?.();
|
|
605
|
+
},
|
|
606
|
+
);
|
|
513
607
|
|
|
514
608
|
const retry = makeRetry(ctx);
|
|
515
609
|
|
|
516
610
|
return (
|
|
517
|
-
<
|
|
611
|
+
<Errored
|
|
518
612
|
fallback={(error, reset) => {
|
|
519
613
|
resetFn = reset;
|
|
520
|
-
|
|
614
|
+
const err = error();
|
|
615
|
+
if (err instanceof NotFoundError) {
|
|
521
616
|
return resolveNotFoundBoundary(ctx, props.depth);
|
|
522
617
|
}
|
|
523
|
-
if (
|
|
524
|
-
return resolveUnauthenticatedBoundary(ctx, props.depth,
|
|
618
|
+
if (err instanceof UnauthenticatedError) {
|
|
619
|
+
return resolveUnauthenticatedBoundary(ctx, props.depth, err, retry);
|
|
525
620
|
}
|
|
526
|
-
if (
|
|
527
|
-
return resolveUnauthorizedBoundary(ctx, props.depth,
|
|
621
|
+
if (err instanceof UnauthorizedError) {
|
|
622
|
+
return resolveUnauthorizedBoundary(ctx, props.depth, err, retry);
|
|
528
623
|
}
|
|
529
|
-
return resolveErrorBoundary(ctx, props.depth,
|
|
624
|
+
return resolveErrorBoundary(ctx, props.depth, err, reset, retry);
|
|
530
625
|
}}
|
|
531
626
|
>
|
|
532
627
|
{props.children}
|
|
533
|
-
</
|
|
628
|
+
</Errored>
|
|
534
629
|
) as JSX.Element;
|
|
535
630
|
}
|
|
536
631
|
|
package/src/outlet/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Accessor
|
|
1
|
+
import type { Accessor } from "solid-js";
|
|
2
|
+
import type { JSX } from "@solidjs/web";
|
|
2
3
|
import type { GlobalBoundaries } from "../boundaries/index.ts";
|
|
3
4
|
import type { InvalidateOptions, MatchCache, PrefetchCache } from "../caches/index.ts";
|
|
4
5
|
import type { ExtractT, Translator } from "../i18n/index.ts";
|
|
@@ -88,7 +89,9 @@ export interface InterceptedState {
|
|
|
88
89
|
backgroundLocation: ProviderLocation;
|
|
89
90
|
dismiss: () => void;
|
|
90
91
|
match: ClientMatch;
|
|
92
|
+
params: Record<string, string | string[]>;
|
|
91
93
|
render: string;
|
|
94
|
+
search: SearchParams;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
export interface ClientMatch {
|
|
@@ -145,6 +148,8 @@ export interface InternalNavigateOptions {
|
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
export type PrefetchOptions<TPath extends RoutePaths = RoutePaths> = {
|
|
151
|
+
/** Viewport/render: load route modules only, no per-URL NDJSON. */
|
|
152
|
+
modulesOnly?: boolean;
|
|
148
153
|
params?: Record<string, unknown>;
|
|
149
154
|
to: TPath;
|
|
150
155
|
} & RouteSearchProps<TPath>;
|
|
@@ -161,6 +166,8 @@ export interface FlareProviderContext {
|
|
|
161
166
|
location: Accessor<ProviderLocation>;
|
|
162
167
|
matchCache: MatchCache;
|
|
163
168
|
matches: Accessor<ClientMatch[]>;
|
|
169
|
+
/** Bumps when match objects are reused in place (instant shell → NDJSON). */
|
|
170
|
+
matchesEpoch?: Accessor<number>;
|
|
164
171
|
navigate: (options: InternalNavigateOptions) => Promise<void>;
|
|
165
172
|
navigationPhase: Accessor<NavigationPhase>;
|
|
166
173
|
notFound: Accessor<boolean>;
|
|
@@ -174,6 +181,7 @@ export interface FlareProviderContext {
|
|
|
174
181
|
setHydrated: (v: boolean) => void;
|
|
175
182
|
setIntercepted: (state: InterceptedState | null) => void;
|
|
176
183
|
setMatches: (matches: ClientMatch[]) => void;
|
|
184
|
+
touchMatches?: () => void;
|
|
177
185
|
setNavigationPhase: (phase: NavigationPhase) => void;
|
|
178
186
|
setNotFound: (notFound: boolean) => void;
|
|
179
187
|
setParams: (params: Record<string, string | string[]>) => void;
|
|
@@ -247,6 +247,12 @@ export async function handleCdnRequest(
|
|
|
247
247
|
return;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/* Background SWR hop — skip the cache read and refill the same key */
|
|
251
|
+
if (getHeader(req, "flare-cdn-revalidate") === "1") {
|
|
252
|
+
interceptResponse(req, res, next, store, method, pathname);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
250
256
|
/* Probe cache — first without Vary to discover what Vary headers were stored */
|
|
251
257
|
const probeKey = buildCdnCacheKey(method, pathname, [], {});
|
|
252
258
|
const probeEntry = await store.get(probeKey);
|
|
@@ -292,14 +298,21 @@ export async function handleCdnRequest(
|
|
|
292
298
|
|
|
293
299
|
if (freshness.state === "stale-revalidate") {
|
|
294
300
|
serveCached(res, cached, "STALE");
|
|
295
|
-
/*
|
|
301
|
+
/* Keep the stale entry; background-fill the same key */
|
|
296
302
|
if (!revalidating.has(cacheKey)) {
|
|
297
303
|
revalidating.add(cacheKey);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
304
|
+
const host = getHeader(req, "host") ?? "localhost";
|
|
305
|
+
const revalidateUrl = `http://${host}${req.url ?? pathname}`;
|
|
306
|
+
const headers = new Headers();
|
|
307
|
+
for (const [key, val] of Object.entries(req.headers)) {
|
|
308
|
+
if (typeof val === "string") headers.set(key, val);
|
|
301
309
|
}
|
|
302
|
-
|
|
310
|
+
headers.set("flare-cdn-revalidate", "1");
|
|
311
|
+
fetch(revalidateUrl, { headers })
|
|
312
|
+
.catch(() => {})
|
|
313
|
+
.finally(() => {
|
|
314
|
+
revalidating.delete(cacheKey);
|
|
315
|
+
});
|
|
303
316
|
}
|
|
304
317
|
return;
|
|
305
318
|
}
|