@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.
Files changed (85) hide show
  1. package/README.md +44 -19
  2. package/package.json +10 -7
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +7 -0
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +93 -35
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +164 -56
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -4,14 +4,15 @@ import {
4
4
  createEffect,
5
5
  createMemo,
6
6
  createSignal,
7
- ErrorBoundary,
8
- type JSX,
7
+ Errored,
8
+ Loading,
9
9
  onCleanup,
10
- onMount,
10
+ onSettled,
11
11
  Show,
12
- Suspense,
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
- /** Exported for SSR — SSR injects context directly on sharedConfig.context */
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
- props.matchCache.invalidate(options);
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: props.boundaries,
126
- caseSensitive: props.caseSensitive,
139
+ boundaries: staticProps.boundaries,
140
+ caseSensitive: staticProps.caseSensitive,
127
141
  hydrated,
128
142
  intercepted,
129
143
  invalidate,
130
144
  isNavigating,
131
- layouts: props.layouts,
132
- localeConfig: props.localeConfig,
145
+ layouts: staticProps.layouts,
146
+ localeConfig: staticProps.localeConfig,
133
147
  location,
134
- matchCache: props.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: props.prefetchCache,
147
- resolvers: props.resolvers,
148
- routeTree: props.routeTree,
149
- routerCacheDefaults: props.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: () => props.matchCache.getAll(),
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: () => props.prefetchCache.clear(),
218
+ clearPrefetchCache: () => staticProps.prefetchCache.clear(),
203
219
  getCacheStats: () => ({
204
- entries: props.matchCache.getAll().map((c) => ({
220
+ entries: staticProps.matchCache.getAll().map((c) => ({
205
221
  age: Date.now() - c.updatedAt,
206
222
  matchId: c.matchId,
207
223
  })),
208
- matchCount: props.matchCache.getAll().length,
209
- prefetchCount: props.prefetchCache.size(),
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
- onMount(() => {
242
+ onSettled(() => {
227
243
  const unsubscribe = channel.onMessage((msg: ChannelMessage) => {
228
244
  if (msg.type === "invalidate") {
229
- props.matchCache.invalidate(msg.options);
230
- props.prefetchCache.clear();
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
- onCleanup(unsubscribe);
259
+ return unsubscribe;
244
260
  });
245
261
 
246
- createEffect(() => {
247
- if (props.onContextReady) {
248
- props.onContextReady(ctx);
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.Provider value={ctx}>{props.children}</RouterContext.Provider>;
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(() => setActiveBlocker(null));
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
- const m = ctx.matches().find((match) => match.virtualPath === options.from);
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 createTranslator((data() ?? {}) as Record<string, Record<string, string>>, locale());
402
+ return liveTranslator(data, locale);
366
403
  }) as never,
367
- useMatch: (options) => createMemo(() => ctx.matches().find((match) => match.virtualPath === options.from)),
368
- useParams: ((_options: { from: string }) => createMemo(() => ctx.params())) as never,
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
- const m = ctx.matches().find((match) => match.virtualPath === options.from);
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 createTranslator((data() ?? {}) as Record<string, Record<string, string>>, locale());
445
+ return liveTranslator(data, locale);
387
446
  }) as never,
388
- useSearch: ((_options: { from: string }) => createMemo(() => ctx.search())) as never,
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.Provider value={depth + 1}>
474
+ <DepthContext value={depth + 1}>
408
475
  <OutletContent depth={depth} fallback={props?.fallback} />
409
- </DepthContext.Provider>
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
- const isPage = () => m()._type === "render";
426
- const renderFn = () => m().render;
427
- const hasError = createMemo(() => m().error);
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
- * Pipeline errors rendered via reactive Show — must react to SPA nav
438
- * match updates. Static `if (current.error)` only works for SSR initial render.
439
- */
440
- return (
441
- <Show
442
- fallback={
443
- <ErrorBoundaryWrapper depth={props.depth} match={m()}>
444
- <Suspense fallback={props.fallback ?? null}>
445
- {(() => {
446
- const fn = renderFn();
447
- const p = renderProps();
448
- return fn(p);
449
- })()}
450
- </Suspense>
451
- </ErrorBoundaryWrapper>
452
- }
453
- when={hasError()}
454
- >
455
- {renderMatchError(ctx, props.depth, hasError())}
456
- </Show>
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 ErrorBoundary to clear, must re-run loaders) */
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 ErrorBoundary so it exits
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 ErrorBoundary fallback.
596
+ * stays stuck in Errored fallback.
506
597
  */
507
- createEffect(() => {
508
- /* Track reactive deps — resets ErrorBoundary on SPA navigation */
509
- void props.match.virtualPath;
510
- void props.match.error;
511
- if (resetFn) resetFn();
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
- <ErrorBoundary
611
+ <Errored
518
612
  fallback={(error, reset) => {
519
613
  resetFn = reset;
520
- if (error instanceof NotFoundError) {
614
+ const err = error();
615
+ if (err instanceof NotFoundError) {
521
616
  return resolveNotFoundBoundary(ctx, props.depth);
522
617
  }
523
- if (error instanceof UnauthenticatedError) {
524
- return resolveUnauthenticatedBoundary(ctx, props.depth, error, retry);
618
+ if (err instanceof UnauthenticatedError) {
619
+ return resolveUnauthenticatedBoundary(ctx, props.depth, err, retry);
525
620
  }
526
- if (error instanceof UnauthorizedError) {
527
- return resolveUnauthorizedBoundary(ctx, props.depth, error, retry);
621
+ if (err instanceof UnauthorizedError) {
622
+ return resolveUnauthorizedBoundary(ctx, props.depth, err, retry);
528
623
  }
529
- return resolveErrorBoundary(ctx, props.depth, error, reset, retry);
624
+ return resolveErrorBoundary(ctx, props.depth, err, reset, retry);
530
625
  }}
531
626
  >
532
627
  {props.children}
533
- </ErrorBoundary>
628
+ </Errored>
534
629
  ) as JSX.Element;
535
630
  }
536
631
 
@@ -1,4 +1,5 @@
1
- import type { Accessor, JSX } from "solid-js";
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
- /* Background: delete stale entry so next request gets fresh SSR */
301
+ /* Keep the stale entry; background-fill the same key */
296
302
  if (!revalidating.has(cacheKey)) {
297
303
  revalidating.add(cacheKey);
298
- store.delete(cacheKey).catch(() => {});
299
- if (probeCdn && probeCdn.varyHeaders.length > 0) {
300
- store.delete(probeKey).catch(() => {});
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
- setTimeout(() => revalidating.delete(cacheKey), 100);
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
  }