@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
package/src/ssr/index.tsx CHANGED
@@ -1,9 +1,10 @@
1
- import type { JSX } from "solid-js";
2
- import { createComponent, Hydration, renderToStream as solidRenderToStream } from "solid-js/web";
1
+ import { getNextChildId, getOwner } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import { createComponent, Hydration, renderToStream as solidRenderToStream } from "@solidjs/web";
3
4
  import type { GlobalBoundaries } from "../boundaries/index.ts";
4
5
  import { BroadcastProvider } from "../broadcast/provider.tsx";
5
6
  import { createMatchCache, createPrefetchCache } from "../caches/index.ts";
6
- import { setSSRContext } from "../components/ssr-context.tsx";
7
+ import { SSRContextProvider } from "../components/ssr-context.tsx";
7
8
  import type { DeferContext, DeferredEntry } from "../defer/index.ts";
8
9
  import { isDeferred } from "../defer/index.ts";
9
10
  import { DirectionProvider } from "../direction.ts";
@@ -28,8 +29,10 @@ import type { ServerLogEntry } from "@lovrozagar/flare/server-context";
28
29
  import { clearScopedStyles, getScopedStyles, RUNTIME_SHEET_ID } from "../styles/index.ts";
29
30
  import { CRITICAL_SHEET_ID, injectCriticalAppend, type SxCssManifest } from "./critical-css.ts";
30
31
  import { ThemeProvider } from "../theme.ts";
32
+ import { GLOBAL_DEFER, GLOBAL_QUERIES } from "../protocol.ts";
31
33
  import { parseSearchParams, type SearchParams } from "../url/index.ts";
32
34
  import { renderHeadToHtml } from "./head.ts";
35
+ import { hoistHydrationHeadMarkers } from "./hoist-head-markers.ts";
33
36
  import { buildHeadPrefix } from "./head-prefix.ts";
34
37
 
35
38
  export { mergeHeadConfigs } from "../internal.ts";
@@ -394,9 +397,19 @@ function RootRenderer(props: {
394
397
  preloaderContext: Record<string, unknown> | undefined;
395
398
  renderFn: (p: RenderProps) => JSX.Element;
396
399
  }): JSX.Element {
400
+ /*
401
+ * Client `createContext` children() consumes one hydration id in this
402
+ * owner before the layout runs; the server's children() memos do not.
403
+ * Advance the server counter so <html> `_hk` matches hydrate.
404
+ */
405
+ const owner = getOwner();
406
+ if (owner?.id != null) getNextChildId(owner);
397
407
  const router = useRouter();
408
+ /* Getter so <Outlet> does not consume hydration child ids before <html>. */
398
409
  return props.renderFn({
399
- children: <Outlet />,
410
+ get children() {
411
+ return <Outlet />;
412
+ },
400
413
  loaderData: props.data,
401
414
  location: props.location,
402
415
  preloaderContext: props.preloaderContext,
@@ -419,7 +432,7 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
419
432
  const matches = config.matches;
420
433
 
421
434
  return () => {
422
- setSSRContext({
435
+ const ssrCtxValue = {
423
436
  direction: config.router?.direction,
424
437
  entryScript: config.entryScript,
425
438
  flareStateScript,
@@ -427,7 +440,7 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
427
440
  nonce: config.nonce,
428
441
  resolvedHead: config.resolvedHead,
429
442
  theme: config.router?.theme,
430
- });
443
+ };
431
444
 
432
445
  const lastMatch = matches[matches.length - 1];
433
446
  const location: ProviderLocation = {
@@ -452,8 +465,8 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
452
465
  * Full-document hydration: providers wrap root layout so components
453
466
  * inside the root layout (e.g. NavigationProgress) have RouterContext.
454
467
  *
455
- * SSR: Hydration > Theme > Direction > FlareProvider > rootRenderFn({children: Outlet})
456
- * Client: Dummy > Theme > Direction > FlareProvider > rootRenderFn({children: Outlet})
468
+ * SSR and client: Hydration > QCP? > SSRContextProvider > Theme >
469
+ * Direction > Broadcast > FlareProvider > rootRenderFn({children: Outlet})
457
470
  *
458
471
  * All JSX inside the Hydration boundary — no pre-evaluated constants outside.
459
472
  */
@@ -480,37 +493,39 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
480
493
  * inside the Provider's context scope during Solid SSR.
481
494
  */
482
495
  const renderInner = () => (
483
- <ThemeProvider config={config.router?.theme}>
484
- <DirectionProvider config={config.router?.direction}>
485
- <BroadcastProvider>
486
- <FlareProvider
487
- boundaries={rootBoundaries}
488
- caseSensitive={config.router?.caseSensitive}
489
- initialLocation={location}
490
- layouts={config.router?.layouts ?? {}}
491
- localeConfig={config.router?.locale}
492
- matchCache={matchCache}
493
- matches={clientMatches}
494
- params={location.params}
495
- prefetchCache={prefetchCache}
496
- resolvers={new Map()}
497
- routeTree={config.router?.routeTree ?? createTreeNode()}
498
- search={location.search}
499
- >
500
- {rootRenderFn ? (
501
- <RootRenderer
502
- data={rootMatch?.loaderData}
503
- location={location}
504
- preloaderContext={rootMatch?.preloaderContext}
505
- renderFn={rootRenderFn}
506
- />
507
- ) : (
508
- <Outlet />
509
- )}
510
- </FlareProvider>
511
- </BroadcastProvider>
512
- </DirectionProvider>
513
- </ThemeProvider>
496
+ <SSRContextProvider value={ssrCtxValue}>
497
+ <ThemeProvider config={config.router?.theme}>
498
+ <DirectionProvider config={config.router?.direction}>
499
+ <BroadcastProvider>
500
+ <FlareProvider
501
+ boundaries={rootBoundaries}
502
+ caseSensitive={config.router?.caseSensitive}
503
+ initialLocation={location}
504
+ layouts={config.router?.layouts ?? {}}
505
+ localeConfig={config.router?.locale}
506
+ matchCache={matchCache}
507
+ matches={clientMatches}
508
+ params={location.params}
509
+ prefetchCache={prefetchCache}
510
+ resolvers={new Map()}
511
+ routeTree={config.router?.routeTree ?? createTreeNode()}
512
+ search={location.search}
513
+ >
514
+ {rootRenderFn ? (
515
+ RootRenderer({
516
+ data: rootMatch?.loaderData,
517
+ location,
518
+ preloaderContext: rootMatch?.preloaderContext,
519
+ renderFn: rootRenderFn,
520
+ })
521
+ ) : (
522
+ <Outlet />
523
+ )}
524
+ </FlareProvider>
525
+ </BroadcastProvider>
526
+ </DirectionProvider>
527
+ </ThemeProvider>
528
+ </SSRContextProvider>
514
529
  );
515
530
 
516
531
  return (
@@ -532,18 +547,15 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
532
547
  * Inject resolved head tags, CSP nonce meta, and scoped styles before </head>.
533
548
  * Returns updated buffer with head content injected, or original buffer if </head> not found.
534
549
  *
535
- * Head structure (order matters for first paint + Chrome's preload scanner):
536
- * 1. <meta name="csp-nonce"> with nonce attribute (browser hides it from DOM/CSS)
537
- * 2. viewport (if the app omitted one)
538
- * 3. theme / direction / locale blocking scripts — before CSS so first land is themed
539
- * 4. <link rel="modulepreload"> + stylesheets
540
- * 5. Solid's SSR output (layout ThemeScript is a duplicate, harmless)
541
- * 6. Resolved <head> tags (title, meta, etc.)
542
- * 7. Scoped styles
550
+ * Head structure:
551
+ * 1. Solid's hydratable head children (`<!--$-->` first — required for Solid 2)
552
+ * 2. CSP nonce / viewport / theme / direction / locale scripts (headPrefix)
553
+ * 3. modulepreload + stylesheets
554
+ * 4. Resolved <head> tags (title, meta, etc.)
555
+ * 5. Scoped styles
543
556
  *
544
- * Theme scripts must precede stylesheets. Modulepreloads stay after those
545
- * tiny inline scripts so Chrome can still mark them isLinkPreload when CSP
546
- * has no 'strict-dynamic' (crbug.com/702612).
557
+ * Prefix is appended (not prepended) so hydrate can walk Solid's markers.
558
+ * Theme scripts still run in `<head>` before `<body>` is parsed.
547
559
  */
548
560
  function injectHeadContent(
549
561
  buffer: string,
@@ -565,11 +577,12 @@ function injectHeadContent(
565
577
 
566
578
  let headSuffix = "";
567
579
  const headHtml = renderHeadToHtml(config.resolvedHead, config.nonce);
568
- if (headHtml) headSuffix += headHtml;
580
+ /* Runtime scoped sheet first so route `head().custom.styles` win at equal specificity. */
569
581
  if (scopedStyles) {
570
582
  const safeStyles = scopedStyles.replace(/<\/style\b/gi, "<\\/style");
571
583
  headSuffix += `<style id="${RUNTIME_SHEET_ID}" nonce="${escapedNonce}">${safeStyles}</style>`;
572
584
  }
585
+ if (headHtml) headSuffix += headHtml;
573
586
  /* Critical-CSS placeholder — body not known yet; late-inject populates it near </body>. */
574
587
  if (config.sxCssManifest) {
575
588
  const nonceAttr = config.nonce ? ` nonce="${escapedNonce}"` : "";
@@ -617,8 +630,8 @@ function injectHeadContent(
617
630
  headSuffix += `<style nonce="${escapedNonce}">body{${cssText}}</style>`;
618
631
  }
619
632
 
620
- const result = buffer.replace(/<head[^>]*>/, `$&${headPrefix}`);
621
- return result.replace("</head>", `${headSuffix}${extraSuffix}</head>`);
633
+ const result = buffer.replace("</head>", `${headPrefix}${headSuffix}${extraSuffix}</head>`);
634
+ return hoistHydrationHeadMarkers(result);
622
635
  }
623
636
 
624
637
  /**
@@ -688,7 +701,7 @@ export function renderToStream(config: SSRConfig): SSRResult {
688
701
  const decoder = new TextDecoder();
689
702
 
690
703
  /*
691
- * Solid only injects _$HY when async work exists (Suspense, deferred data).
704
+ * Solid only injects _$HY when async work exists (Loading, deferred data).
692
705
  * For sync-only rendering, _$HY is never set, but client `hydrate()` always
693
706
  * reads `globalThis._$HY.done`. Inject a minimal _$HY init unconditionally.
694
707
  */
@@ -805,14 +818,6 @@ export function renderToStream(config: SSRConfig): SSRResult {
805
818
 
806
819
  /* Stream deferred resolution scripts after HTML */
807
820
  if (config.deferContexts && config.deferContexts.size > 0) {
808
- /* Install QC tracking so deferred callbacks' setQueryData calls are captured */
809
- let trackedQC: { drain(): Array<{ data: unknown; key: unknown[]; staleTime?: number }> } | undefined;
810
- if (config.queryClient) {
811
- const { createTrackedQueryClient } = await import("../query-client");
812
- const typed = config.queryClient as Parameters<typeof createTrackedQueryClient>[0];
813
- trackedQC = createTrackedQueryClient(typed);
814
- }
815
-
816
821
  const entries: DeferredEntry[] = [];
817
822
  for (const ctx of config.deferContexts.values()) {
818
823
  for (const e of ctx.entries()) {
@@ -821,41 +826,53 @@ export function renderToStream(config: SSRConfig): SSRResult {
821
826
  }
822
827
  const escapedNonce = escapeAttr(config.nonce);
823
828
  const esc = (s: string) => JSON.stringify(s).replace(/</g, "\\u003c");
824
- await Promise.allSettled(
825
- entries.map(async (entry) => {
826
- const resolverKey = esc(`${entry.matchId}:${entry.key}`);
827
- try {
828
- const data = await entry.promise;
829
- const json = JSON.stringify(data).replace(/</g, "\\u003c");
830
- controller.enqueue(
831
- encoder.encode(
832
- `<script nonce="${escapedNonce}">(self.__flare_q=self.__flare_q||[]).push([${resolverKey},${json}])</script>`,
833
- ),
834
- );
835
- } catch (err) {
836
- const msg = err instanceof Error ? err.message : String(err);
837
- const safeMsg = esc(msg);
838
- controller.enqueue(
839
- encoder.encode(
840
- `<script nonce="${escapedNonce}">(self.__flare_q=self.__flare_q||[]).push([${resolverKey},${safeMsg},true])</script>`,
841
- ),
842
- );
843
- }
844
-
845
- /* Stream QC delta entries accumulated during this deferred resolve */
846
- if (trackedQC) {
847
- const qcEntries = trackedQC.drain();
848
- for (const qcEntry of qcEntries) {
849
- const qcJson = JSON.stringify(qcEntry).replace(/</g, "\\u003c");
829
+
830
+ const streamDeferred = async (trackedQC?: {
831
+ drain(): Array<{ data: unknown; key: unknown[]; staleTime?: number }>;
832
+ }) => {
833
+ await Promise.allSettled(
834
+ entries.map(async (entry) => {
835
+ const resolverKey = esc(`${entry.matchId}:${entry.key}`);
836
+ try {
837
+ const data = await entry.promise;
838
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
839
+ controller.enqueue(
840
+ encoder.encode(
841
+ `<script nonce="${escapedNonce}">(self.${GLOBAL_DEFER}=self.${GLOBAL_DEFER}||[]).push([${resolverKey},${json}])</script>`,
842
+ ),
843
+ );
844
+ } catch (err) {
845
+ const msg = err instanceof Error ? err.message : String(err);
846
+ const safeMsg = esc(msg);
850
847
  controller.enqueue(
851
848
  encoder.encode(
852
- `<script nonce="${escapedNonce}">(self.__flare_qc=self.__flare_qc||[]).push([${qcJson}])</script>`,
849
+ `<script nonce="${escapedNonce}">(self.${GLOBAL_DEFER}=self.${GLOBAL_DEFER}||[]).push([${resolverKey},${safeMsg},true])</script>`,
853
850
  ),
854
851
  );
855
852
  }
856
- }
857
- }),
858
- );
853
+
854
+ if (trackedQC) {
855
+ const qcEntries = trackedQC.drain();
856
+ for (const qcEntry of qcEntries) {
857
+ const qcJson = JSON.stringify(qcEntry).replace(/</g, "\\u003c");
858
+ controller.enqueue(
859
+ encoder.encode(
860
+ `<script nonce="${escapedNonce}">(self.${GLOBAL_QUERIES}=self.${GLOBAL_QUERIES}||[]).push([${qcJson}])</script>`,
861
+ ),
862
+ );
863
+ }
864
+ }
865
+ }),
866
+ );
867
+ };
868
+
869
+ if (config.queryClient) {
870
+ const { withTrackedQueryClient } = await import("../query-client");
871
+ const typed = config.queryClient as Parameters<typeof withTrackedQueryClient>[0];
872
+ await withTrackedQueryClient(typed, streamDeferred);
873
+ } else {
874
+ await streamDeferred();
875
+ }
859
876
  }
860
877
 
861
878
  controller.close();
@@ -57,6 +57,17 @@ export function isDeferredMarker(value: unknown): value is DeferredMarker {
57
57
  return obj.__deferred === true && "key" in obj && typeof obj.key === "string";
58
58
  }
59
59
 
60
+ const MAX_DEFERRED_WALK = 50;
61
+
62
+ /** Prefetch stores `{ __deferred, key }` with no promise. Hydrated shells use `__key` + `promise`. */
63
+ export function hasRawDeferredMarkers(data: unknown, depth = 0): boolean {
64
+ if (depth > MAX_DEFERRED_WALK) return false;
65
+ if (data === null || data === undefined || typeof data !== "object") return false;
66
+ if (isDeferredMarker(data) && !("promise" in data)) return true;
67
+ if (Array.isArray(data)) return data.some((v) => hasRawDeferredMarkers(v, depth + 1));
68
+ return Object.values(data as Record<string, unknown>).some((v) => hasRawDeferredMarkers(v, depth + 1));
69
+ }
70
+
60
71
  export function hydrateLoaderData(matchId: string, data: unknown, resolvers: Map<string, DeferredResolver>): unknown {
61
72
  if (data === null || data === undefined) return data;
62
73
  if (typeof data !== "object") return data;
@@ -68,6 +79,10 @@ export function hydrateLoaderData(matchId: string, data: unknown, resolvers: Map
68
79
  resolveFn = resolve;
69
80
  rejectFn = reject;
70
81
  });
82
+ /* Handler attached in the same tick as construction so a later
83
+ reject() (NDJSON error, navigation cancel) is not unhandled
84
+ before Await's then() attaches. Multiple handlers still fire. */
85
+ promise.catch(() => {});
71
86
  resolvers.set(`${matchId}:${data.key}`, { reject: rejectFn, resolve: resolveFn });
72
87
  return { __deferred: true, __key: data.key, promise };
73
88
  }
@@ -86,15 +101,15 @@ export function hydrateLoaderData(matchId: string, data: unknown, resolvers: Map
86
101
  }
87
102
 
88
103
  /**
89
- * Register `self.__flare_r(resolverKey, data)` global so streamed `<script>` tags
90
- * from SSR deferred resolution can resolve hydrated promise shells.
104
+ * SSR deferred chunks push into `self.__flare_defer`. `__flare_r` / `__flare_re`
105
+ * are leftovers and are no longer installed.
91
106
  */
92
107
  declare global {
93
- var __flare_q:
108
+ var __flare_defer:
94
109
  | Array<[string, unknown, boolean?]>
95
110
  | { push: (entry: [string, unknown, boolean?]) => number }
96
111
  | undefined;
97
- var __flare_qc: Array<[unknown]> | { push: (entry: [unknown]) => number } | undefined;
112
+ var __flare_queries: Array<[unknown]> | { push: (entry: [unknown]) => number } | undefined;
98
113
  var __flare_r: ((key: string, data: unknown) => void) | undefined;
99
114
  var __flare_re: ((key: string, message: string) => void) | undefined;
100
115
  }
@@ -102,7 +117,7 @@ declare global {
102
117
  /*
103
118
  * Multi-instance registry: supports multiple Flare apps hydrating on the same page.
104
119
  * Each call to installDeferredResolver registers its resolver map. The global
105
- * __flare_r/__flare_re/__flare_q search ALL active maps when resolving a key.
120
+ * `__flare_defer` push proxy searches ALL active maps when resolving a key.
106
121
  * Globals are only cleaned when every active map is drained AND no pending entries remain.
107
122
  */
108
123
  const activeInstances = new Set<Map<string, DeferredResolver>>();
@@ -131,7 +146,7 @@ function cleanupIfAllEmpty(): void {
131
146
  }
132
147
  globalThis.__flare_r = undefined;
133
148
  globalThis.__flare_re = undefined;
134
- globalThis.__flare_q = undefined;
149
+ globalThis.__flare_defer = undefined;
135
150
  activeInstances.clear();
136
151
  }
137
152
 
@@ -140,7 +155,7 @@ function cleanupIfAllEmpty(): void {
140
155
  * then install live resolver for any late-arriving chunks.
141
156
  *
142
157
  * SSR scripts push `[key, data]` (or `[key, msg, true]` for errors)
143
- * into `self.__flare_q` array. This function drains them, then replaces
158
+ * into `self.__flare_defer` array. This function drains them, then replaces
144
159
  * the array push with a direct resolver so future chunks resolve immediately.
145
160
  *
146
161
  * Safe for multiple Flare instances on the same page — resolvers compose
@@ -149,8 +164,10 @@ function cleanupIfAllEmpty(): void {
149
164
  export function installDeferredResolver(resolvers: Map<string, DeferredResolver>): void {
150
165
  if (typeof globalThis === "undefined") return;
151
166
 
152
- /* Reset registry if globals were cleaned externally (e.g. tests) */
153
- if (!globalThis.__flare_r && activeInstances.size > 0) {
167
+ /* Reset leftover instances when this is a new page load: no live proxy
168
+ * (undefined after cleanup, or a fresh SSR array to drain). */
169
+ const deferIsLiveProxy = globalThis.__flare_defer !== undefined && !Array.isArray(globalThis.__flare_defer);
170
+ if (!deferIsLiveProxy && activeInstances.size > 0) {
154
171
  activeInstances.clear();
155
172
  pendingEntries.length = 0;
156
173
  }
@@ -158,7 +175,7 @@ export function installDeferredResolver(resolvers: Map<string, DeferredResolver>
158
175
  activeInstances.add(resolvers);
159
176
 
160
177
  /* Drain any buffered entries from SSR script tags */
161
- const queue = globalThis.__flare_q;
178
+ const queue = globalThis.__flare_defer;
162
179
  if (Array.isArray(queue)) {
163
180
  for (const entry of queue) {
164
181
  resolveOrBuffer(entry[0], entry[1], Boolean(entry[2]));
@@ -174,28 +191,20 @@ export function installDeferredResolver(resolvers: Map<string, DeferredResolver>
174
191
  }
175
192
  }
176
193
 
177
- /* Install live resolvers for chunks that stream after hydration */
178
- globalThis.__flare_r = (key: string, data: unknown) => {
179
- resolveOrBuffer(key, data, false);
180
- cleanupIfAllEmpty();
181
- };
182
- globalThis.__flare_re = (key: string, message: string) => {
183
- resolveOrBuffer(key, message, true);
184
- cleanupIfAllEmpty();
185
- };
186
-
187
194
  /*
188
- * Trap __flare_q so late-arriving SSR script pushes resolve immediately.
189
- * SSR scripts do `(self.__flare_q=self.__flare_q||[]).push([...])` — after
195
+ * Trap __flare_defer so late-arriving SSR script pushes resolve immediately.
196
+ * SSR scripts do `(self.__flare_defer=self.__flare_defer||[]).push([...])` — after
190
197
  * drain, new pushes must route through the live resolver, not the array.
191
198
  */
192
- globalThis.__flare_q = {
199
+ globalThis.__flare_defer = {
193
200
  push(entry: [string, unknown, boolean?]) {
194
201
  resolveOrBuffer(entry[0], entry[1], Boolean(entry[2]));
195
202
  cleanupIfAllEmpty();
196
203
  return 0;
197
204
  },
198
205
  };
206
+ globalThis.__flare_r = undefined;
207
+ globalThis.__flare_re = undefined;
199
208
 
200
209
  /* If no resolvers needed (all data was instant), clean up immediately */
201
210
  cleanupIfAllEmpty();
@@ -205,9 +214,12 @@ export function installDeferredResolver(resolvers: Map<string, DeferredResolver>
205
214
  * Drain buffered deferred QC entries from SSR streamed `<script>` tags,
206
215
  * then install live proxy for late-arriving entries.
207
216
  *
208
- * SSR scripts push `[{data, key, staleTime?}]` into `self.__flare_qc` array.
217
+ * SSR scripts push `[{data, key, staleTime?}]` into `self.__flare_queries` array.
209
218
  * This function drains them, then replaces the array with a push-proxy
210
219
  * so future scripts hydrate immediately.
220
+ *
221
+ * Call after `useQuery` observers have attached (a microtask after
222
+ * `solidHydrate`). Draining earlier writes into a cache with no subscribers.
211
223
  */
212
224
  export function installQueryCacheResolver(queryClient: unknown): void {
213
225
  if (typeof globalThis === "undefined") return;
@@ -228,7 +240,7 @@ export function installQueryCacheResolver(queryClient: unknown): void {
228
240
  }
229
241
 
230
242
  /* Drain any buffered entries from SSR script tags */
231
- const queue = globalThis.__flare_qc;
243
+ const queue = globalThis.__flare_queries;
232
244
  if (Array.isArray(queue)) {
233
245
  for (const entry of queue) {
234
246
  applyEntry(entry[0]);
@@ -236,7 +248,7 @@ export function installQueryCacheResolver(queryClient: unknown): void {
236
248
  }
237
249
 
238
250
  /* Install push-proxy for late-arriving SSR scripts */
239
- globalThis.__flare_qc = {
251
+ globalThis.__flare_queries = {
240
252
  push(entry: [unknown]) {
241
253
  applyEntry(entry[0]);
242
254
  return 0;