@lovrozagar/flare 0.1.2 → 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 +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 +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
@@ -46,28 +46,60 @@ export class UnauthorizedError extends Error {
46
46
  }
47
47
 
48
48
  const SAFE_PROTOCOLS = new Set(["http:", "https:"]);
49
+ const SENTINEL_ORIGIN = "https://flare.invalid";
50
+ const HAS_SCHEME = /^[a-zA-Z][a-zA-Z+\-.]*:/;
49
51
 
50
52
  /**
51
53
  * Allowlist-based redirect URL validation using the URL constructor.
52
- * Lets the platform's URL parser handle all encoding/unicode normalization
53
- * instead of a regex blocklist that can be bypassed.
54
+ * Lets the platform's URL parser handle encoding, backslash normalization,
55
+ * and unicode instead of a regex blocklist that can be bypassed.
56
+ *
57
+ * Protocol-relative (`//host`) is not a same-origin path — `startsWith("/")`
58
+ * would treat it as one. Internal `to` must stay on the sentinel origin.
54
59
  */
55
- function validateExternalRedirectUrl(href: string): string {
56
- /* Relative paths are safe — no protocol to exploit */
57
- if (href.startsWith("/") || href.startsWith(".")) return href;
60
+ function validateRedirectUrl(raw: string, mode: "internal" | "external"): string {
61
+ if (/[\0\r\n]/.test(raw)) {
62
+ throw new Error(`Unsafe redirect URL: ${raw}`);
63
+ }
58
64
 
59
65
  let parsed: URL;
60
66
  try {
61
- parsed = new URL(href, "http://localhost");
67
+ parsed = new URL(raw, SENTINEL_ORIGIN);
62
68
  } catch {
63
- throw new Error(`Unsafe redirect URL: ${href}`);
69
+ throw new Error(`Unsafe redirect URL: ${raw}`);
64
70
  }
65
71
 
66
72
  if (!SAFE_PROTOCOLS.has(parsed.protocol)) {
67
- throw new Error(`Unsafe redirect URL: ${href}`);
73
+ throw new Error(`Unsafe redirect URL: ${raw}`);
74
+ }
75
+
76
+ const sameOrigin = parsed.origin === SENTINEL_ORIGIN;
77
+ if (mode === "internal") {
78
+ let decodedPath = parsed.pathname;
79
+ try {
80
+ decodedPath = decodeURIComponent(parsed.pathname);
81
+ } catch {
82
+ /* keep pathname */
83
+ }
84
+ if (
85
+ !sameOrigin ||
86
+ !raw.startsWith("/") ||
87
+ raw.startsWith("//") ||
88
+ raw.includes("\\") ||
89
+ parsed.pathname.startsWith("//") ||
90
+ decodedPath.includes("\\") ||
91
+ decodedPath.startsWith("//")
92
+ ) {
93
+ throw new Error(`Unsafe redirect URL: ${raw}`);
94
+ }
95
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
96
+ }
97
+
98
+ if (!sameOrigin && !HAS_SCHEME.test(raw.trimStart())) {
99
+ throw new Error(`Unsafe redirect URL: ${raw}`);
68
100
  }
69
101
 
70
- return href;
102
+ return raw;
71
103
  }
72
104
 
73
105
  export class RedirectResponse extends Error {
@@ -80,10 +112,10 @@ export class RedirectResponse extends Error {
80
112
  constructor(options: RawRedirectOptions) {
81
113
  super("Redirect");
82
114
  if ("href" in options) {
83
- this.url = validateExternalRedirectUrl(options.href);
115
+ this.url = validateRedirectUrl(options.href, "external");
84
116
  this.external = true;
85
117
  } else {
86
- this.url = options.to;
118
+ this.url = validateRedirectUrl(options.to, "internal");
87
119
  this.external = false;
88
120
  }
89
121
  this.status = options.status ?? 303;
@@ -136,6 +168,8 @@ export function redirect(options: {
136
168
  if (options.to === undefined) {
137
169
  throw new Error("redirect() requires either 'to' or 'href'");
138
170
  }
171
+ /* Validate the raw path before buildUrl collapses leading slashes (`//host` → `/host`) */
172
+ validateRedirectUrl(options.to, "internal");
139
173
  const url = buildUrl({ params: options.params, search: options.search, to: options.to });
140
174
  throw new RedirectResponse({ replace: options.replace, status: options.status, to: url });
141
175
  }
@@ -1,4 +1,5 @@
1
- import { For, type JSX } from "solid-js";
1
+ import { For } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import { useSSRContext } from "../components/ssr-context.tsx";
3
4
  import type { Font } from "./types.ts";
4
5
 
@@ -1,7 +1,9 @@
1
- import { createMemo, createSignal, For, type JSX, Show, splitProps } from "solid-js";
2
- import { Dynamic } from "solid-js/web";
1
+ import { createMemo, createSignal, For, omit, Show } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import { Dynamic } from "@solidjs/web";
3
4
  import type { FlattenedError } from "../errors/index.ts";
4
5
  import { ServerFnValidationError } from "../errors/index.ts";
6
+ import { FORM_FN_FIELD, serverFnPath } from "../protocol.ts";
5
7
  import type { ServerFn } from "../server-fn/index.ts";
6
8
 
7
9
  /* Duplicated here to avoid importing server-context (uses node:async_hooks)
@@ -51,16 +53,20 @@ interface FormOwnProps<TInput, TOutput> {
51
53
  export type FormProps<TInput, TOutput> = FormOwnProps<TInput, TOutput> &
52
54
  Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "action" | "children" | "enctype" | "method">;
53
55
 
56
+ /** Seed `form.error()` after a no-JS PE POST with form-level validation errors. */
57
+ export function seedFormErrorFromSsr(ssrCtx: FormActionContext | undefined): Error | null {
58
+ if (!ssrCtx?.formErrors.length) return null;
59
+ return new ServerFnValidationError({
60
+ fieldErrors: ssrCtx.fieldErrors,
61
+ formErrors: ssrCtx.formErrors,
62
+ });
63
+ }
64
+
54
65
  /* ── Form component ────────────────────────────────────────────────── */
55
66
 
56
67
  export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.Element {
57
- const [local, rest] = splitProps(props as FormOwnProps<TInput, TOutput> & Record<string, unknown>, [
58
- "action",
59
- "children",
60
- "enctype",
61
- "onError",
62
- "onSuccess",
63
- ]);
68
+ const local = props as FormOwnProps<TInput, TOutput> & Record<string, unknown>;
69
+ const rest = omit(local, "action", "children", "enctype", "onError", "onSuccess");
64
70
 
65
71
  const reg = createMemo(() => local.action._registration);
66
72
  const fnId = createMemo(() => reg()?.id ?? "");
@@ -70,7 +76,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
70
76
  const ssrCtx = typeof window === "undefined" && _getFormActionContext ? _getFormActionContext(fnId()) : undefined;
71
77
 
72
78
  const [pending, setPending] = createSignal(false);
73
- const [error, setError] = createSignal<Error | null>(null);
79
+ const [error, setError] = createSignal<Error | null>(seedFormErrorFromSsr(ssrCtx));
74
80
  const [fieldErrors, setFieldErrors] = createSignal<Record<string, string[]>>(ssrCtx?.fieldErrors ?? {});
75
81
  const [result, setResult] = createSignal<TOutput | undefined>(undefined);
76
82
 
@@ -116,7 +122,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
116
122
  setFieldErrors({});
117
123
 
118
124
  try {
119
- const url = `/_fn/${id}/${name}`;
125
+ const url = serverFnPath(id, name);
120
126
  const res = await fetch(url, {
121
127
  body: formData,
122
128
  method: "POST",
@@ -165,7 +171,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
165
171
  method="post"
166
172
  onSubmit={handleSubmit}
167
173
  >
168
- <input name="__flare_fn" type="hidden" value={fnId()} />
174
+ <input name={FORM_FN_FIELD} type="hidden" value={fnId()} />
169
175
  {local.children(formCtx)}
170
176
  </form>
171
177
  ) as JSX.Element;
@@ -11,6 +11,7 @@ export type PrefetchStrategy = false | "intent" | "render" | "viewport";
11
11
  export type ExtractedStaticDeferMode = "resolve" | "stream";
12
12
 
13
13
  export interface ExtractedCacheConfig {
14
+ cdnTags?: string[];
14
15
  client?: {
15
16
  cacheDeferred?: boolean;
16
17
  gcTime?: number;
@@ -37,6 +38,7 @@ export interface RouteDefinition {
37
38
  exportName: string;
38
39
  filePath: string;
39
40
  cache: ExtractedCacheConfig;
41
+ hasAuthorize?: boolean;
40
42
  hasInput: boolean;
41
43
  intercept?: ExtractedInterceptConfig;
42
44
  responseRoute: boolean;
@@ -76,6 +78,23 @@ function writeGenFile(filePath: string, body: string, generator: string): void {
76
78
  writeFileSync(filePath, fullContent, "utf-8");
77
79
  }
78
80
 
81
+ /**
82
+ * Recursive source listing, normalized to posix separators and sorted.
83
+ *
84
+ * `readdirSync` order is filesystem- and runtime-dependent — node, bun, and
85
+ * deno disagree — and generated files are checked in, so an unsorted scan
86
+ * makes `routes.gen.ts` churn based on who ran the build. Sorting by code
87
+ * unit (not `localeCompare`, which follows ICU locale data) pins the order.
88
+ * Returns [] when the directory is missing.
89
+ */
90
+ function listSourceEntries(srcPath: string): string[] {
91
+ try {
92
+ return (readdirSync(srcPath, { recursive: true }) as string[]).map((entry) => entry.replace(/\\/g, "/")).sort();
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
79
98
  const BUILDER_TO_TYPE: Record<string, RouteDefinition["type"]> = {
80
99
  createLayout: "layout",
81
100
  createPage: "page",
@@ -206,6 +225,41 @@ function extractNestedBlock(body: string, key: string): string | null {
206
225
  return null;
207
226
  }
208
227
 
228
+ function extractStringArray(text: string, key: string): string[] | undefined {
229
+ const re = new RegExp(`${key}\\s*:\\s*\\[`);
230
+ const match = re.exec(text);
231
+ if (!match) return undefined;
232
+
233
+ const start = (match.index ?? 0) + match[0].length;
234
+ let depth = 1;
235
+ let end = start;
236
+ for (let i = start; i < text.length; i++) {
237
+ const skipped = skipStringOrComment(text, i);
238
+ if (skipped !== i) {
239
+ i = skipped;
240
+ continue;
241
+ }
242
+ if (text[i] === "[") depth++;
243
+ else if (text[i] === "]") {
244
+ depth--;
245
+ if (depth === 0) {
246
+ end = i;
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ const inner = text.slice(start, end);
252
+ const items: string[] = [];
253
+ const itemRe = /["'`]([^"'`]*)["'`]/g;
254
+ let itemMatch: RegExpExecArray | null = itemRe.exec(inner);
255
+ while (itemMatch) {
256
+ const value = itemMatch[1];
257
+ if (value) items.push(value);
258
+ itemMatch = itemRe.exec(inner);
259
+ }
260
+ return items;
261
+ }
262
+
209
263
  function extractNumeric(text: string, key: string, unit?: "ms" | "s"): number | undefined {
210
264
  /* try duration string first: key: "5m" */
211
265
  const dm = DURATION_STR_RE(key).exec(text);
@@ -295,6 +349,13 @@ export function extractCacheFromChain(chainText: string): ExtractedCacheConfig {
295
349
  config.isr = true;
296
350
  }
297
351
 
352
+ /* cdn: { tags: ["a", "b"] } — static string arrays only; function tags are computed at render */
353
+ const cdnBlock = extractNestedBlock(body, "cdn");
354
+ if (cdnBlock) {
355
+ const tags = extractStringArray(cdnBlock, "tags");
356
+ if (tags && tags.length > 0) config.cdnTags = tags;
357
+ }
358
+
298
359
  return config;
299
360
  }
300
361
 
@@ -436,6 +497,7 @@ export function extractRouteDefinitions(src: string, filePath: string): RouteDef
436
497
  const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(chainText);
437
498
  const hasAuthenticate = /\.authenticate\s*\(/.test(chainText);
438
499
  const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
500
+ const hasAuthorize = /\.authorize\s*\(/.test(chainText);
439
501
  const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(chainText);
440
502
  const responseRoute = /\.response\s*\(/.test(chainText);
441
503
  const cache = extractCacheFromChain(chainText);
@@ -446,6 +508,7 @@ export function extractRouteDefinitions(src: string, filePath: string): RouteDef
446
508
  cache,
447
509
  exportName,
448
510
  filePath,
511
+ hasAuthorize,
449
512
  hasInput,
450
513
  intercept,
451
514
  responseRoute,
@@ -581,6 +644,11 @@ function formatRouteMeta(def: RouteDefinition): string {
581
644
  const parts: string[] = [];
582
645
  if (def.authenticateMode === true) parts.push("authenticate: true");
583
646
  else if (def.authenticateMode === "optional") parts.push(`authenticate: "optional"`);
647
+ if (def.hasAuthorize) parts.push("authorize: true");
648
+ if (def.cache.cdnTags && def.cache.cdnTags.length > 0) {
649
+ const tagLit = def.cache.cdnTags.map((t) => `"${escapeCodegenStr(t)}"`).join(", ");
650
+ parts.push(`tags: [${tagLit}]`);
651
+ }
584
652
 
585
653
  if (def.cache.client) {
586
654
  const cp: string[] = [];
@@ -1106,17 +1174,7 @@ export function scanSourceFiles(options: ScanOptions): RouteDefinition[] {
1106
1174
  const prefix = options.ignorePrefix ?? "_";
1107
1175
  const defs: RouteDefinition[] = [];
1108
1176
 
1109
- let entries: string[];
1110
- try {
1111
- entries = readdirSync(srcPath, { recursive: true }) as string[];
1112
- } catch {
1113
- return defs;
1114
- }
1115
-
1116
- for (const entry of entries) {
1117
- /* Normalize to posix separators */
1118
- const rel = entry.replace(/\\/g, "/");
1119
-
1177
+ for (const rel of listSourceEntries(srcPath)) {
1120
1178
  /* Skip non-ts files */
1121
1179
  if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) continue;
1122
1180
 
@@ -1254,15 +1312,7 @@ export function inspectFsCodegenLayout(options: ScanOptions): FsLayoutIssue[] {
1254
1312
  const prefix = options.ignorePrefix ?? "_";
1255
1313
  const issues: FsLayoutIssue[] = [];
1256
1314
 
1257
- let entries: string[];
1258
- try {
1259
- entries = readdirSync(srcPath, { recursive: true }) as string[];
1260
- } catch {
1261
- return issues;
1262
- }
1263
-
1264
- for (const entry of entries) {
1265
- const rel = entry.replace(/\\/g, "/");
1315
+ for (const rel of listSourceEntries(srcPath)) {
1266
1316
  if (!rel.endsWith(".ts") && !rel.endsWith(".tsx") && !rel.endsWith(".js") && !rel.endsWith(".jsx")) continue;
1267
1317
  if (rel.includes("_gen/") || rel.includes("node_modules")) continue;
1268
1318
  if (rel.endsWith(".gen.ts") || rel.endsWith(".gen.tsx")) continue;
@@ -1421,16 +1471,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
1421
1471
  const routesDir = "routes";
1422
1472
  const defs: RouteDefinition[] = [];
1423
1473
 
1424
- let entries: string[];
1425
- try {
1426
- entries = readdirSync(srcPath, { recursive: true }) as string[];
1427
- } catch {
1428
- return defs;
1429
- }
1430
-
1431
- for (const entry of entries) {
1432
- const rel = entry.replace(/\\/g, "/");
1433
-
1474
+ for (const rel of listSourceEntries(srcPath)) {
1434
1475
  /* Only process files under routes/ */
1435
1476
  if (!rel.startsWith(`${routesDir}/`)) continue;
1436
1477
 
@@ -1464,6 +1505,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
1464
1505
  const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(content);
1465
1506
  const hasAuthenticate = /\.authenticate\s*\(/.test(content);
1466
1507
  const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
1508
+ const hasAuthorize = /\.authorize\s*\(/.test(content);
1467
1509
  const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(content);
1468
1510
  const responseRoute = /\.response\s*\(/.test(content);
1469
1511
  const cache = extractCacheFromChain(content);
@@ -1474,6 +1516,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
1474
1516
  cache,
1475
1517
  exportName: "route",
1476
1518
  filePath: posix.join(options.srcDir ?? "src", rel),
1519
+ hasAuthorize,
1477
1520
  hasInput,
1478
1521
  intercept,
1479
1522
  responseRoute,
@@ -564,6 +564,13 @@ function addCssLinkForRoute(matchId: string, href: string): void {
564
564
  function addCssStyleForRoute(matchId: string, children: string): void {
565
565
  if (typeof document === "undefined") return;
566
566
  const el = document.createElement("style");
567
+ const meta = document.querySelector?.('meta[name="csp-nonce"]');
568
+ const tagged = document.querySelector?.("script[nonce], style[nonce]") as HTMLElement | null;
569
+ const nonce = meta?.getAttribute("content") || tagged?.nonce || tagged?.getAttribute("nonce") || "";
570
+ if (nonce) {
571
+ el.setAttribute("nonce", nonce);
572
+ el.nonce = nonce;
573
+ }
567
574
  el.setAttribute("data-flare-route", matchId);
568
575
  el.textContent = children;
569
576
  document.head.appendChild(el);
@@ -150,6 +150,12 @@ export function createScrollStore(maxSize = 200, ttl = DEFAULT_SCROLL_TTL): Scro
150
150
 
151
151
  return {
152
152
  get(key: string): ScrollPosition | null {
153
+ const ts = timestamps.get(key);
154
+ if (ts !== undefined && Date.now() - ts > ttl) {
155
+ store.delete(key);
156
+ timestamps.delete(key);
157
+ return null;
158
+ }
153
159
  return store.get(key) ?? null;
154
160
  },
155
161
 
@@ -3,8 +3,10 @@ import swConfig from "virtual:flare-sw-config";
3
3
 
4
4
  declare const __FLARE_IS_DEV__: boolean | undefined;
5
5
  const isDevDefault = typeof __FLARE_IS_DEV__ === "boolean" ? __FLARE_IS_DEV__ : false;
6
- import type { JSX } from "solid-js";
7
- import { createComponent, render, hydrate as solidHydrate } from "solid-js/web";
6
+ import { flush } from "solid-js";
7
+ import type { JSX } from "@solidjs/web";
8
+ import { createComponent, Hydration, render, hydrate as solidHydrate } from "@solidjs/web";
9
+ import { SSRContextProvider } from "../components/ssr-context.tsx";
8
10
  import { createChannel } from "../broadcast/channel.ts";
9
11
  import { BroadcastProvider } from "../broadcast/provider.tsx";
10
12
  import type { MatchCache } from "../caches/index.ts";
@@ -17,9 +19,11 @@ import {
17
19
  extractRootBoundaries,
18
20
  hydrateHeadState,
19
21
  loadRouteModules,
22
+ applyMatchCacheTags,
20
23
  populateMatchCache,
21
24
  } from "../hydration/index.ts";
22
25
  import { onceIdle } from "../internal/once-idle.ts";
26
+ import { ATTR_HYDRATED } from "../protocol.ts";
23
27
  import { setupNavigation } from "../navigation/index.ts";
24
28
  import type { LoadedRouteModule } from "../navigation/types.ts";
25
29
  import { FlareProvider, Outlet, useRouter } from "../outlet/index.tsx";
@@ -55,13 +59,10 @@ export interface HydrateOptions {
55
59
  onInteraction?: () => void;
56
60
  }
57
61
 
58
- /**
59
- * Matches SSR's Hydration component depth.
60
- * Hydration is a single-child-passthrough, Dummy matches its depth
61
- * so hydration keys align between SSR and client.
62
- */
63
- function Dummy(props: { children: JSX.Element }): JSX.Element {
64
- return props.children;
62
+ function readDocumentNonce(): string {
63
+ if (typeof document === "undefined") return "";
64
+ const meta = document.querySelector('meta[name="csp-nonce"]');
65
+ return meta?.getAttribute("content") || meta?.getAttribute("nonce") || "";
65
66
  }
66
67
 
67
68
  function RootRenderer(props: {
@@ -71,8 +72,11 @@ function RootRenderer(props: {
71
72
  renderFn: (p: RenderProps) => JSX.Element;
72
73
  }): JSX.Element {
73
74
  const router = useRouter();
75
+ /* Getter so <Outlet> does not consume hydration child ids before <html>. */
74
76
  return props.renderFn({
75
- children: <Outlet />,
77
+ get children() {
78
+ return <Outlet />;
79
+ },
76
80
  loaderData: props.data,
77
81
  location: props.location,
78
82
  preloaderContext: props.preloaderContext,
@@ -156,7 +160,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
156
160
  }
157
161
  const { QueryClientProvider } = await import("../query-client");
158
162
  QCP = QueryClientProvider as typeof QCP;
159
- installQueryCacheResolver(queryClientInstance);
163
+ /* `__flare_queries` is drained after solidHydrate — observers must attach first. */
160
164
  }
161
165
 
162
166
  /* Build composed rewrite: basePath (if any) + user rewrite */
@@ -178,6 +182,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
178
182
  const rootLayout = modules.layouts.find((m) => m._type === "root-layout");
179
183
  const nonRootLayouts = modules.layouts.filter((m) => m._type !== "root-layout");
180
184
  const allModules: LoadedRouteModule[] = [...nonRootLayouts, modules.page];
185
+ applyMatchCacheTags(matchCache, allModules, modules.params, search);
181
186
 
182
187
  /* Build error map from per-match error names (survives serialization) */
183
188
  const errorMap = new Map<string, Error>();
@@ -199,61 +204,71 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
199
204
  const rootMatchState = rootLayout ? state.matches.find((m) => m.virtualPath === rootLayout.virtualPath) : undefined;
200
205
 
201
206
  /*
202
- * Full-document hydration: providers wrap root layout so components
203
- * inside root layout (e.g. NavigationProgress) have RouterContext.
207
+ * Full-document hydration. SSR and client wrappers must match —
208
+ * Solid 2 `_hk` ids follow the owner tree.
204
209
  *
205
- * SSR: Hydration > QCP? > Theme > Direction > Broadcast > FlareProvider > rootRenderFn({children: Outlet})
206
- * Client: Dummy > QCP? > Theme > Direction > Broadcast > FlareProvider > rootRenderFn({children: Outlet})
210
+ * Both: Hydration > QCP? > SSRContextProvider > Theme > Direction >
211
+ * Broadcast > FlareProvider > rootRenderFn({children: Outlet})
207
212
  */
208
213
  const channel = createChannel();
209
214
  const renderInner = () => (
210
- <ThemeProvider config={r.theme}>
211
- <DirectionProvider config={r.direction}>
212
- <BroadcastProvider value={channel}>
213
- <FlareProvider
214
- boundaries={rootBoundaries}
215
- caseSensitive={r.caseSensitive}
216
- layouts={r.layouts}
217
- localeConfig={r.locale}
218
- matchCache={matchCache}
219
- matches={initialMatches}
220
- onContextReady={(ctx) => {
221
- resolveCtx(ctx);
222
- }}
223
- params={state.params}
224
- prefetchCache={prefetchCache}
225
- resolvers={state.resolvers}
226
- routerCacheDefaults={r.cache?.client || undefined}
227
- routeTree={r.routeTree}
228
- search={search}
229
- >
230
- {rootLayout?.render ? (
231
- <RootRenderer
232
- data={rootMatchState?.loaderData}
233
- location={{
234
- hash: window.location.hash,
235
- params: state.params,
236
- pathname: state.pathname,
237
- search,
238
- url: new URL(window.location.href),
239
- variablePath: "",
240
- virtualPath: rootLayout.virtualPath,
241
- }}
242
- preloaderContext={rootMatchState?.preloaderContext}
243
- renderFn={rootLayout.render}
244
- />
245
- ) : (
246
- <Outlet />
247
- )}
248
- </FlareProvider>
249
- </BroadcastProvider>
250
- </DirectionProvider>
251
- </ThemeProvider>
215
+ <SSRContextProvider
216
+ value={{
217
+ direction: r.direction,
218
+ flareStateScript: "",
219
+ isServer: false,
220
+ nonce: readDocumentNonce(),
221
+ theme: r.theme,
222
+ }}
223
+ >
224
+ <ThemeProvider config={r.theme}>
225
+ <DirectionProvider config={r.direction}>
226
+ <BroadcastProvider value={channel}>
227
+ <FlareProvider
228
+ boundaries={rootBoundaries}
229
+ caseSensitive={r.caseSensitive}
230
+ layouts={r.layouts}
231
+ localeConfig={r.locale}
232
+ matchCache={matchCache}
233
+ matches={initialMatches}
234
+ onContextReady={(ctx) => {
235
+ resolveCtx(ctx);
236
+ }}
237
+ params={state.params}
238
+ prefetchCache={prefetchCache}
239
+ resolvers={state.resolvers}
240
+ routerCacheDefaults={r.cache?.client || undefined}
241
+ routeTree={r.routeTree}
242
+ search={search}
243
+ >
244
+ {rootLayout?.render ? (
245
+ RootRenderer({
246
+ data: rootMatchState?.loaderData,
247
+ location: {
248
+ hash: window.location.hash,
249
+ params: state.params,
250
+ pathname: state.pathname,
251
+ search,
252
+ url: new URL(window.location.href),
253
+ variablePath: "",
254
+ virtualPath: rootLayout.virtualPath,
255
+ },
256
+ preloaderContext: rootMatchState?.preloaderContext,
257
+ renderFn: rootLayout.render,
258
+ })
259
+ ) : (
260
+ <Outlet />
261
+ )}
262
+ </FlareProvider>
263
+ </BroadcastProvider>
264
+ </DirectionProvider>
265
+ </ThemeProvider>
266
+ </SSRContextProvider>
252
267
  );
253
268
 
254
269
  solidHydrate(
255
270
  () => (
256
- <Dummy>
271
+ <Hydration>
257
272
  {QCP && queryClientInstance
258
273
  ? createComponent(QCP, {
259
274
  get children() {
@@ -262,12 +277,27 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
262
277
  client: queryClientInstance,
263
278
  })
264
279
  : renderInner()}
265
- </Dummy>
280
+ </Hydration>
266
281
  ),
267
282
  document,
268
283
  );
284
+ flush();
269
285
  finishHydration();
270
286
 
287
+ if (queryClientInstance) {
288
+ /*
289
+ * TanStack `useQuery` (no HydrationCoordinator) attaches its cache
290
+ * subscriber on a microtask after the hydration replay. Streamed
291
+ * `__flare_queries` must land after that attach — applying it earlier
292
+ * writes deferred data into a cache with no observers, and the
293
+ * SSR-serialized fallback stays on screen.
294
+ */
295
+ await Promise.resolve();
296
+ await Promise.resolve();
297
+ installQueryCacheResolver(queryClientInstance);
298
+ flush();
299
+ }
300
+
271
301
  replaceHistoryState(window.location.pathname, state.params, window.location.search, {
272
302
  hash: window.location.hash,
273
303
  historyIndex: 0,
@@ -352,7 +382,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
352
382
  render(() => <DevErrorOverlay />, overlayRoot);
353
383
  }
354
384
 
355
- document.documentElement.setAttribute("data-hydrated", "");
385
+ document.documentElement.setAttribute(ATTR_HYDRATED, "");
356
386
  }
357
387
 
358
388
  /* Service worker registration / unregistration */
@@ -1,6 +1,5 @@
1
- import { batch } from "solid-js";
2
1
  import type { GlobalBoundaries } from "../boundaries/index.ts";
3
- import type { CachedMatch, MatchCache } from "../caches/index.ts";
2
+ import { resolveCacheTags, type CachedMatch, type MatchCache } from "../caches/index.ts";
4
3
  import { applyPerRouteHeads, initRouteHierarchy } from "../head-client/index.ts";
5
4
  import { isRenderFn, retryImport } from "../internal.ts";
6
5
  import type { LoadedRouteModule, LoadedRouteModules } from "../navigation/types.ts";
@@ -80,10 +79,8 @@ export function buildMatches(ctx: FlareProviderContext, modules: LoadedRouteModu
80
79
  };
81
80
  });
82
81
 
83
- batch(() => {
84
- ctx.setMatches(clientMatches);
85
- ctx.setParams(modules.params);
86
- });
82
+ ctx.setMatches(clientMatches);
83
+ ctx.setParams(modules.params);
87
84
  }
88
85
 
89
86
  export function extractRootBoundaries(rootLayout: LoadedRouteModule | undefined): GlobalBoundaries {
@@ -171,6 +168,29 @@ export function populateMatchCache(
171
168
  }
172
169
  }
173
170
 
171
+ export function applyMatchCacheTags(
172
+ matchCache: MatchCache,
173
+ modules: LoadedRouteModule[],
174
+ params: Record<string, string | string[]>,
175
+ search: SearchParams,
176
+ ): void {
177
+ for (const mod of modules) {
178
+ const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
179
+ const matchId = computeMatchId({
180
+ loaderDeps: () => deps,
181
+ params,
182
+ routeId: mod.virtualPath,
183
+ search,
184
+ });
185
+ const entry = matchCache.get(matchId);
186
+ if (!entry) continue;
187
+ const tags = resolveCacheTags(mod.cache, params);
188
+ if (!tags) continue;
189
+ entry.tags = tags;
190
+ matchCache.set(entry);
191
+ }
192
+ }
193
+
174
194
  export function hydrateHeadState(state: Pick<HydrationState, "ph">): void {
175
195
  if (state.ph) {
176
196
  /* First entry is root layout — its title is the fallback when pages don't define .head() */