@lovrozagar/flare 0.1.2 → 0.2.1

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 +42 -19
  2. package/package.json +8 -6
  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 +86 -29
  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 +119 -38
  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 +166 -58
  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
@@ -1,4 +1,5 @@
1
- import { createMemo, type JSX, onCleanup, Show, splitProps } from "solid-js";
1
+ import { createEffect, createMemo, createSignal, omit, Show } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import { applyRewriteOutput, isExternal, navigate, prefetch } from "../navigation/index.ts";
3
4
  import { useRouterContext } from "../outlet/index.tsx";
4
5
  import type { RouteParamsProps, RoutePaths, RouteSearchProps } from "../route-builder/register.ts";
@@ -39,7 +40,7 @@ export type ExternalLinkProps = FlareAnchorProps & {
39
40
 
40
41
  export type LinkProps<TPath extends RoutePaths = RoutePaths> = InternalLinkProps<TPath> | ExternalLinkProps;
41
42
 
42
- /* Flattened internal type for splitProps — avoids TS intersection issues with Omit */
43
+ /* Flattened internal type for omit — avoids TS intersection issues with Omit */
43
44
  interface LinkPropsInternal {
44
45
  activeClass?: string;
45
46
  activeProps?: FlareAnchorProps;
@@ -83,7 +84,9 @@ function isDangerousHref(href: string): boolean {
83
84
  export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Element {
84
85
  const ctx = useRouterContext();
85
86
 
86
- const [local, rest] = splitProps(props as unknown as LinkPropsInternal, [
87
+ const local = props as unknown as LinkPropsInternal;
88
+ const rest = omit(
89
+ local,
87
90
  "activeClass",
88
91
  "activeProps",
89
92
  "children",
@@ -107,19 +110,24 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
107
110
  "target",
108
111
  "to",
109
112
  "viewTransition",
110
- ]);
113
+ );
111
114
 
112
- const resolvedHref = createMemo(() => {
113
- if (local.href !== undefined) {
114
- if (isDangerousHref(local.href)) return "#";
115
- return local.href;
116
- }
117
- const raw = buildUrl({
115
+ const internalHref = createMemo(() => {
116
+ if (local.href !== undefined) return undefined;
117
+ return buildUrl({
118
118
  hash: local.hash,
119
119
  params: local.params,
120
120
  search: local.search,
121
121
  to: local.to ?? "",
122
122
  });
123
+ });
124
+
125
+ const resolvedHref = createMemo(() => {
126
+ if (local.href !== undefined) {
127
+ if (isDangerousHref(local.href)) return "#";
128
+ return local.href;
129
+ }
130
+ const raw = internalHref() ?? "";
123
131
  if (isDangerousHref(raw)) return "#";
124
132
  return applyRewriteOutput(raw);
125
133
  });
@@ -132,8 +140,8 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
132
140
 
133
141
  const routePrefetch = createMemo(() => {
134
142
  if (local.href !== undefined) return undefined;
135
- const h = resolvedHref();
136
- if (isExternal(h)) return undefined;
143
+ const h = internalHref();
144
+ if (!h || isExternal(h)) return undefined;
137
145
  try {
138
146
  const url = new URL(h, typeof window !== "undefined" ? window.location.href : "http://localhost/");
139
147
  const match = matchRoute(ctx.routeTree, url.pathname, ctx.caseSensitive, toLocaleMatch(ctx.localeConfig));
@@ -226,15 +234,27 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
226
234
  return;
227
235
  }
228
236
 
237
+ let to = local.to ?? "";
238
+ let hash = local.hash;
239
+ if (!local.to && local.href !== undefined) {
240
+ try {
241
+ const url = new URL(h, typeof window !== "undefined" ? window.location.href : "http://localhost/");
242
+ to = url.pathname + url.search;
243
+ hash = hash ?? (url.hash || undefined);
244
+ } catch {
245
+ to = h;
246
+ }
247
+ }
248
+
229
249
  navigate({
230
- hash: local.hash,
250
+ hash,
231
251
  params: local.params,
232
252
  replace: local.replace,
233
253
  revalidate: local.revalidate,
234
254
  scroll: local.scroll,
235
255
  search: local.search,
236
256
  shallow: local.shallow,
237
- to: local.to ?? "",
257
+ to,
238
258
  viewTransition: local.viewTransition,
239
259
  });
240
260
  }
@@ -244,8 +264,14 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
244
264
  if (local.href !== undefined) return;
245
265
  const h = resolvedHref();
246
266
  if (isExternal(h)) return;
247
- /* params + search already resolved into h by resolvedHref() — passing them again would double-apply */
248
- prefetch({ to: h });
267
+ /* params + search already resolved into h by resolvedHref() — passing them again would double-apply.
268
+ * Intent prefetches that URL's NDJSON shell. Viewport/render only warm JS once per route. */
269
+ const strategy = effectivePrefetch();
270
+ if (strategy === "viewport" || strategy === "render") {
271
+ prefetch({ modulesOnly: true, to: h });
272
+ } else {
273
+ prefetch({ to: h });
274
+ }
249
275
  }
250
276
 
251
277
  function handleIntent(): void {
@@ -288,49 +314,62 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
288
314
  };
289
315
  }
290
316
 
291
- function setupPrefetchBehavior(el: HTMLAnchorElement): void {
292
- if (local.href !== undefined) return;
293
- if (isExternal(resolvedHref())) return;
294
-
295
- const strategy = effectivePrefetch();
296
-
297
- if (strategy === "intent") {
298
- el.addEventListener("focus", handleIntent);
299
- el.addEventListener("touchstart", handleIntent, { passive: true });
300
- onCleanup(() => {
301
- el.removeEventListener("focus", handleIntent);
302
- el.removeEventListener("touchstart", handleIntent);
303
- });
304
- }
317
+ /* Capture the element; attach listeners in createEffect so cleanup is owned. */
318
+ const [anchor, setAnchor] = createSignal<HTMLAnchorElement | undefined>();
319
+
320
+ createEffect(
321
+ () => {
322
+ const el = anchor();
323
+ const strategy = effectivePrefetch();
324
+ const href = resolvedHref();
325
+ return {
326
+ disabled: !!local.disabled,
327
+ el,
328
+ external: isExternal(href),
329
+ hrefOnly: local.href !== undefined,
330
+ strategy,
331
+ };
332
+ },
333
+ ({ disabled, el, external, hrefOnly, strategy }) => {
334
+ if (!el || disabled || hrefOnly || external) return;
335
+
336
+ if (strategy === "intent") {
337
+ el.addEventListener("focus", handleIntent);
338
+ el.addEventListener("touchstart", handleIntent, { passive: true });
339
+ return () => {
340
+ el.removeEventListener("focus", handleIntent);
341
+ el.removeEventListener("touchstart", handleIntent);
342
+ };
343
+ }
305
344
 
306
- if (strategy === "viewport") {
307
- let observer: IntersectionObserver | undefined;
308
- const cancel = scheduleAfterLoad(() => {
309
- if (typeof IntersectionObserver === "undefined") return;
310
- observer = new IntersectionObserver(
311
- (entries) => {
312
- for (const entry of entries) {
313
- if (entry.isIntersecting) {
314
- triggerPrefetch();
315
- observer?.unobserve(entry.target);
345
+ if (strategy === "viewport") {
346
+ let observer: IntersectionObserver | undefined;
347
+ const cancel = scheduleAfterLoad(() => {
348
+ if (typeof IntersectionObserver === "undefined") return;
349
+ observer = new IntersectionObserver(
350
+ (entries) => {
351
+ for (const entry of entries) {
352
+ if (entry.isIntersecting) {
353
+ triggerPrefetch();
354
+ observer?.unobserve(entry.target);
355
+ }
316
356
  }
317
- }
318
- },
319
- { threshold: 0 },
320
- );
321
- observer.observe(el);
322
- });
323
- onCleanup(() => {
324
- cancel();
325
- observer?.disconnect();
326
- });
327
- }
357
+ },
358
+ { threshold: 0 },
359
+ );
360
+ observer.observe(el);
361
+ });
362
+ return () => {
363
+ cancel();
364
+ observer?.disconnect();
365
+ };
366
+ }
328
367
 
329
- if (strategy === "render") {
330
- const cancel = scheduleAfterLoad(() => triggerPrefetch());
331
- onCleanup(cancel);
332
- }
333
- }
368
+ if (strategy === "render") {
369
+ return scheduleAfterLoad(() => triggerPrefetch());
370
+ }
371
+ },
372
+ );
334
373
 
335
374
  function disabledStyle(): JSX.CSSProperties | string {
336
375
  if (typeof local.style === "string") return `cursor:not-allowed;${local.style}`;
@@ -341,7 +380,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
341
380
  return (
342
381
  <Show
343
382
  fallback={
344
- <span {...rest} aria-disabled="true" class={classes()} style={disabledStyle()} tabIndex={-1}>
383
+ <span {...rest} aria-disabled="true" class={classes()} style={disabledStyle()} tabindex={-1}>
345
384
  {local.children}
346
385
  </span>
347
386
  }
@@ -356,7 +395,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
356
395
  href={resolvedHref()}
357
396
  onClick={handleClick}
358
397
  onMouseEnter={handleIntent}
359
- ref={setupPrefetchBehavior}
398
+ ref={setAnchor}
360
399
  rel={effectiveRel()}
361
400
  target={local.target}
362
401
  >
@@ -1,5 +1,5 @@
1
1
  import type { DeferContext } from "../defer/index.ts";
2
- import { createDeferContext } from "../defer/index.ts";
2
+ import { containsDeferred, createDeferContext } from "../defer/index.ts";
3
3
  import { parseMilliseconds, parseSeconds } from "../duration/index.ts";
4
4
  import {
5
5
  isNotFoundError,
@@ -35,7 +35,7 @@ import { buildVaryHeader } from "../server-handler/etag.ts";
35
35
  import type { FlareStore } from "../store/index.ts";
36
36
  import { noopTracer } from "../tracing/noop.ts";
37
37
  import type { FlareTracer } from "../tracing/types.ts";
38
- import { buildUrl, parseSearchParams, type SearchParams } from "../url/index.ts";
38
+ import { buildUrl, parseSearchParams, serializeSearchParams, type SearchParams } from "../url/index.ts";
39
39
  import { isStandardSchema, issuesToFlattenedError } from "../validation/index.ts";
40
40
 
41
41
  export type { LoaderCause };
@@ -96,6 +96,8 @@ export interface PipelineResult {
96
96
  auth: unknown | null;
97
97
  deferContexts: Map<string, DeferContext>;
98
98
  matches: PipelineMatch[];
99
+ /** Accumulated merge (root → page) for SSR `resolvedHead`. */
100
+ mergedHead?: HeadConfig;
99
101
  }
100
102
 
101
103
  export interface PipelineMatch {
@@ -144,6 +146,7 @@ function buildCdnCacheHeaders(cdn: CdnCacheConfig, params: Record<string, string
144
146
  }
145
147
 
146
148
  interface InternalRoute {
149
+ authenticateError?: Error;
147
150
  authorizeError?: Error;
148
151
  location: Location<Record<string, string | string[]>, SearchParams, string>;
149
152
  preloaderError?: Error;
@@ -153,6 +156,37 @@ interface InternalRoute {
153
156
  validatedSearch: SearchParams;
154
157
  }
155
158
 
159
+ function defaultSsrCacheKey(
160
+ virtualPath: string,
161
+ params: Record<string, string | string[]>,
162
+ search: SearchParams,
163
+ ): string {
164
+ const base = `flare:${virtualPath}:${JSON.stringify(params)}`;
165
+ const searchPart = serializeSearchParams(search);
166
+ return searchPart.length > 0 ? `${base}:${searchPart}` : base;
167
+ }
168
+
169
+ function isRequiredAuth(route: ResolvedRoute): boolean {
170
+ return route.authenticate !== undefined && route.authenticateMode !== "optional";
171
+ }
172
+
173
+ /** Store UnauthenticatedError on the first required-auth route and every subsequent match so loaders skip. */
174
+ function attachUnauthenticated(internalRoutes: InternalRoute[], err: UnauthenticatedError): void {
175
+ let first = -1;
176
+ for (let i = 0; i < internalRoutes.length; i++) {
177
+ const ir = internalRoutes[i];
178
+ if (ir && isRequiredAuth(ir.route)) {
179
+ first = i;
180
+ break;
181
+ }
182
+ }
183
+ if (first < 0) return;
184
+ for (let j = first; j < internalRoutes.length; j++) {
185
+ const ir = internalRoutes[j];
186
+ if (ir) ir.authenticateError = err;
187
+ }
188
+ }
189
+
156
190
  /** Phase 1: Validate route inputs (params + search) and build location objects. */
157
191
  async function buildInternalRoutes(
158
192
  routes: ResolvedRoute[],
@@ -205,6 +239,23 @@ async function buildInternalRoutes(
205
239
  return result;
206
240
  }
207
241
 
242
+ /**
243
+ * Per-match head for client apply (`ph`, NDJSON, match cache).
244
+ * Scalars / objects / images / jsonLd stay merged so upsert and
245
+ * remove-all-then-append fields still see the full set. Concatenate
246
+ * `custom.*` arrays stay on the declaring route so descendants do not replay them.
247
+ */
248
+ function toClientHeadConfig(merged: HeadConfig | undefined, own: HeadConfig | undefined): HeadConfig | undefined {
249
+ if (!merged) return undefined;
250
+ const client: HeadConfig = { ...merged };
251
+ if (own?.custom !== undefined) {
252
+ client.custom = own.custom;
253
+ } else {
254
+ delete client.custom;
255
+ }
256
+ return client;
257
+ }
258
+
208
259
  export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>): Promise<PipelineResult> {
209
260
  const { abortController, cause, env, localeConfig, prefetch, request, routes, ssrCacheDefaults, url } = config;
210
261
  const tracer = config.tracer ?? noopTracer;
@@ -235,38 +286,43 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
235
286
  const hasAnyAuth = internalRoutes.some((r) => r.route.authenticate !== undefined);
236
287
 
237
288
  if (hasAnyAuth) {
289
+ const hasRequired = internalRoutes.some((r) => isRequiredAuth(r.route));
238
290
  if (!config.authenticateFn) {
239
- /* Only throw if at least one route requires auth (not just optional) */
240
- const hasRequired = internalRoutes.some(
241
- (r) => r.route.authenticate !== undefined && r.route.authenticateMode !== "optional",
242
- );
243
291
  if (hasRequired) {
244
292
  authSpan.setStatus("error");
245
- authSpan.end();
246
- throw new UnauthenticatedError();
293
+ attachUnauthenticated(internalRoutes, new UnauthenticatedError());
247
294
  }
248
295
  } else {
249
- /* find first route with authenticate to get callerData */
250
296
  const firstAuthRoute = internalRoutes.find((r) => r.route.authenticate !== undefined);
251
- auth = await config.authenticateFn({
252
- callerData: firstAuthRoute?.route.authenticate as unknown[] | undefined,
253
- env,
254
- request,
255
- serverContext: getServerContext(),
256
- url,
257
- });
258
-
259
- if (auth === null || auth === undefined) {
260
- /* Only throw for required auth (true or unset mode), not optional */
261
- const hasRequired = internalRoutes.some(
262
- (r) => r.route.authenticate !== undefined && r.route.authenticateMode !== "optional",
263
- );
264
- if (hasRequired) {
297
+ try {
298
+ auth = await config.authenticateFn({
299
+ callerData: firstAuthRoute?.route.authenticate as unknown[] | undefined,
300
+ env,
301
+ request,
302
+ serverContext: getServerContext(),
303
+ url,
304
+ });
305
+ } catch (e: unknown) {
306
+ if (isRedirectResponse(e) || isNotFoundError(e)) {
307
+ authSpan.setStatus("error");
308
+ authSpan.end();
309
+ throw e;
310
+ }
311
+ if (isUnauthenticatedError(e)) {
312
+ authSpan.setStatus("error");
313
+ attachUnauthenticated(internalRoutes, e);
314
+ auth = null;
315
+ } else {
265
316
  authSpan.setStatus("error");
266
317
  authSpan.end();
267
- throw new UnauthenticatedError();
318
+ throw e;
268
319
  }
269
320
  }
321
+
322
+ if (!internalRoutes.some((r) => r.authenticateError) && (auth === null || auth === undefined) && hasRequired) {
323
+ authSpan.setStatus("error");
324
+ attachUnauthenticated(internalRoutes, new UnauthenticatedError());
325
+ }
270
326
  }
271
327
  }
272
328
  authSpan.end();
@@ -292,6 +348,8 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
292
348
  };
293
349
 
294
350
  for (const ir of internalRoutes) {
351
+ if (ir.authenticateError) continue;
352
+
295
353
  /* 3a. Preloader */
296
354
  if (ir.route.preloader && !ir.preloaderError) {
297
355
  const preloaderSpan = tracer.startSpan(`flare.pipeline.preloader:${ir.route.virtualPath}`);
@@ -391,6 +449,18 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
391
449
  const deferContext = createDeferContext(matchId, { prefetch: config.prefetch });
392
450
  deferContexts.set(matchId, deferContext);
393
451
 
452
+ if (ir.authenticateError) {
453
+ return {
454
+ deferContext,
455
+ error: ir.authenticateError,
456
+ loaderData: undefined,
457
+ matchId,
458
+ preloaderContext: ir.preloaderSnapshot,
459
+ route: ir.route,
460
+ status: "error",
461
+ };
462
+ }
463
+
394
464
  /* Authorize failure stored in phase 3b — skip loader, produce error match */
395
465
  if (ir.authorizeError) {
396
466
  return {
@@ -434,14 +504,17 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
434
504
  const ssrConfig = ir.route.cache?.ssr ?? ssrCacheDefaults;
435
505
  let cacheKey: string | undefined;
436
506
  if (ssrConfig && resolvedStore) {
437
- cacheKey = ssrConfig.key
438
- ? ssrConfig.key({ params: ir.validatedParams })
439
- : `flare:${ir.route.virtualPath}:${JSON.stringify(ir.validatedParams)}`;
507
+ const personalized = ir.route.authenticateMode === true || ir.route.authenticateMode === "optional";
508
+ if (ssrConfig.key) {
509
+ cacheKey = ssrConfig.key({ auth, params: ir.validatedParams, search });
510
+ } else if (!personalized) {
511
+ cacheKey = defaultSsrCacheKey(ir.route.virtualPath, ir.validatedParams, search);
512
+ }
440
513
  }
441
514
  if (ssrConfig && resolvedStore && cacheKey) {
442
515
  try {
443
516
  const cached = await resolvedStore.get(cacheKey);
444
- if (cached) {
517
+ if (cached && !containsDeferred(cached.data)) {
445
518
  const isFresh =
446
519
  ssrConfig.staleTime === undefined || Date.now() - cached.storedAt < parseMilliseconds(ssrConfig.staleTime);
447
520
  if (isFresh) {
@@ -488,20 +561,24 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
488
561
  if (config.queryClient) {
489
562
  loaderCtx.queryClient = config.queryClient;
490
563
  }
564
+ const startedAt = Date.now();
491
565
  const loaderData = await ir.route.loader(loaderCtx);
492
566
 
493
- /* Store cache write-back */
494
- if (ssrConfig && resolvedStore && cacheKey) {
567
+ /* Store cache write-back — skip deferred trees (markers have no live promises) */
568
+ if (ssrConfig && resolvedStore && cacheKey && !containsDeferred(loaderData)) {
495
569
  let tags: string[] | undefined;
496
570
  if (ssrConfig.tags) {
497
571
  tags = typeof ssrConfig.tags === "function" ? ssrConfig.tags({ params: ir.validatedParams }) : ssrConfig.tags;
498
572
  }
499
573
  try {
500
- await resolvedStore.set(
501
- cacheKey,
502
- { data: serializeLoaderData(loaderData), storedAt: Date.now(), tags },
503
- ssrConfig.ttl !== undefined ? parseSeconds(ssrConfig.ttl) : undefined,
504
- );
574
+ const existing = await resolvedStore.get(cacheKey);
575
+ if (!existing || existing.storedAt < startedAt) {
576
+ await resolvedStore.set(
577
+ cacheKey,
578
+ { data: serializeLoaderData(loaderData), storedAt: startedAt, tags },
579
+ ssrConfig.ttl !== undefined ? parseSeconds(ssrConfig.ttl) : undefined,
580
+ );
581
+ }
505
582
  } catch (e: unknown) {
506
583
  warn("cache", "Store set failed", e);
507
584
  }
@@ -547,11 +624,12 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
547
624
  let mergedHead: HeadConfig | undefined;
548
625
 
549
626
  for (const match of matches) {
627
+ let routeHead: HeadConfig | undefined;
550
628
  if (match.route.head && match.status === "success") {
551
629
  const location = routeLocationMap.get(match.route);
552
630
  if (location) {
553
631
  try {
554
- const routeHead = match.route.head({
632
+ routeHead = match.route.head({
555
633
  ...urlHelpers,
556
634
  cause,
557
635
  loaderData: match.loaderData,
@@ -573,10 +651,13 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
573
651
  }
574
652
  } catch (e: unknown) {
575
653
  match.headError = e instanceof Error ? e : new Error(String(e));
654
+ routeHead = undefined;
576
655
  }
577
656
  }
578
657
  }
579
- match.headConfig = mergedHead;
658
+ /* Client apply uses this match's own custom.* so concatenate fields
659
+ * are not replayed once per descendant. SSR uses `mergedHead`. */
660
+ match.headConfig = toClientHeadConfig(mergedHead, routeHead);
580
661
  }
581
662
  headSpan.end();
582
663
 
@@ -635,5 +716,5 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
635
716
  }
636
717
  headersSpan.end();
637
718
 
638
- return { auth, deferContexts, matches };
719
+ return { auth, deferContexts, matches, mergedHead };
639
720
  }
@@ -0,0 +1,15 @@
1
+ const DEFAULT_COOKIE_NAME = "flare.locale";
2
+ const COOKIE_NAME_RE = /^[\w.-]+$/;
3
+
4
+ export function formatLocaleCookie(
5
+ locale: string,
6
+ cookieName: string = DEFAULT_COOKIE_NAME,
7
+ opts?: { https?: boolean; maxAge?: number; secure?: boolean },
8
+ ): string {
9
+ const name = COOKIE_NAME_RE.test(cookieName) ? cookieName : DEFAULT_COOKIE_NAME;
10
+ const safe = locale.replace(/[\r\n;\0]/g, "");
11
+ const maxAge = opts?.maxAge ?? 31_536_000;
12
+ const useSecure = opts?.secure ?? opts?.https ?? false;
13
+ const secureFlag = useSecure ? "; Secure" : "";
14
+ return `${name}=${safe}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureFlag}`;
15
+ }
@@ -1,16 +1,8 @@
1
- import {
2
- createContext,
3
- createEffect,
4
- createSignal,
5
- type JSX,
6
- on,
7
- onCleanup,
8
- onMount,
9
- sharedConfig,
10
- useContext,
11
- } from "solid-js";
1
+ import { createContext, createEffect, createSignal, onSettled, untrack, useContext } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
12
3
  import { BroadcastCtx } from "../broadcast/context.ts";
13
4
  import { escapeJsString } from "../theme/index.tsx";
5
+ import { formatLocaleCookie } from "./cookie.ts";
14
6
 
15
7
  /* ── Types ─────────────────────────────────────────────────────────── */
16
8
 
@@ -24,6 +16,8 @@ export interface LocaleConfig {
24
16
  syncDirection?: boolean;
25
17
  }
26
18
 
19
+ export { formatLocaleCookie } from "./cookie.ts";
20
+
27
21
  export interface LocaleContextValue {
28
22
  defaultLocale: string;
29
23
  locale: () => string;
@@ -40,52 +34,47 @@ export function getLocaleScript(opts: LocaleConfig): string {
40
34
 
41
35
  /* ── Context ───────────────────────────────────────────────────────── */
42
36
 
43
- const LocaleCtx = createContext<LocaleContextValue>();
37
+ const LocaleCtx = createContext<LocaleContextValue | null>(null);
44
38
 
45
39
  export function LocaleProvider(props: { children: JSX.Element; config: LocaleConfig; initial?: string }): JSX.Element {
46
- if (sharedConfig.context) {
47
- return props.children;
48
- }
49
-
50
- const cfg = props.config;
40
+ const cfg = untrack(() => props.config);
51
41
  const defaultLocale = cfg.defaultLocale;
52
42
  const cookieName = cfg.cookieName ?? "flare.locale";
53
43
 
54
- /* Capture context at component setup level (not inside onMount) */
44
+ /* Capture context at component setup level (not inside onSettled) */
55
45
  const channel = cfg.broadcast ? useContext(BroadcastCtx) : undefined;
56
46
 
57
47
  /* URL is truth: initial comes from server (URL params), not localStorage */
58
- const initial = props.initial ?? defaultLocale;
48
+ const initial = untrack(() => props.initial) ?? defaultLocale;
59
49
  const [locale, setLocaleSignal] = createSignal(initial);
60
50
 
61
51
  /* Cross-tab sync: BroadcastChannel for locale (opt-in) */
62
- onMount(() => {
52
+ onSettled(() => {
63
53
  if (!channel) return;
64
54
 
65
- const unsubscribe = channel.onMessage((msg) => {
55
+ return channel.onMessage((msg) => {
66
56
  if (msg.type === "locale" && typeof msg.value === "string" && cfg.locales.includes(msg.value)) {
67
57
  setLocaleSignal(msg.value);
68
58
  }
69
59
  });
60
+ });
70
61
 
62
+ if (channel) {
71
63
  createEffect(
72
- on(
73
- locale,
74
- (l) => {
75
- channel.broadcast({ type: "locale", value: l });
76
- },
77
- { defer: true },
78
- ),
64
+ locale,
65
+ (l) => {
66
+ channel.broadcast({ type: "locale", value: l });
67
+ },
68
+ { defer: true },
79
69
  );
70
+ }
80
71
 
81
- onCleanup(unsubscribe);
82
- });
83
-
84
- createEffect(() => {
85
- const l = locale();
72
+ createEffect(locale, (l) => {
86
73
  if (typeof document === "undefined") return;
87
74
  document.documentElement.setAttribute("lang", l);
88
- document.cookie = `${cookieName}=${l}; path=/; max-age=31536000; samesite=lax`;
75
+ document.cookie = formatLocaleCookie(l, cookieName, {
76
+ https: typeof location !== "undefined" && location.protocol === "https:",
77
+ });
89
78
  });
90
79
 
91
80
  const setLocale = (l: string): void => {
@@ -100,7 +89,7 @@ export function LocaleProvider(props: { children: JSX.Element; config: LocaleCon
100
89
  setLocale,
101
90
  };
102
91
 
103
- return <LocaleCtx.Provider value={value}>{props.children}</LocaleCtx.Provider>;
92
+ return <LocaleCtx value={value}>{props.children}</LocaleCtx>;
104
93
  }
105
94
 
106
95
  export function useLocale(): LocaleContextValue {
package/src/locale.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { LocaleScript } from "./components/locale-script.tsx";
2
2
  export type { LocaleConfig, LocaleContextValue } from "./locale/index.tsx";
3
+ export { formatLocaleCookie } from "./locale/cookie.ts";
3
4
  export { buildHreflangLinks, getLocaleScript, LocaleProvider, useLocale } from "./locale/index.tsx";