@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
@@ -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() */
package/src/i18n/index.ts CHANGED
@@ -209,6 +209,14 @@ export interface Translator<
209
209
  ) => unknown;
210
210
  }
211
211
 
212
+ function neutralizeRichValues(values: Record<string, unknown>): Record<string, unknown> {
213
+ const out: Record<string, unknown> = {};
214
+ for (const [key, value] of Object.entries(values)) {
215
+ out[key] = typeof value === "string" ? value.replaceAll("[[", "[\u200B[").replaceAll("]]", "]\u200B]") : value;
216
+ }
217
+ return out;
218
+ }
219
+
212
220
  export function createTranslator<T extends Record<string, Record<string, string>>>(
213
221
  loaded: T,
214
222
  locale?: string,
@@ -235,8 +243,9 @@ export function createTranslator<T extends Record<string, Record<string, string>
235
243
  const template = flat[key];
236
244
  if (template === undefined) return key;
237
245
 
238
- /* First apply ICU formatting if values provided */
239
- const formatted = values ? formatMessage(template, values, locale) : template;
246
+ /* ICU first, but neutralize `[[` / `]]` in user values so they cannot
247
+ inject rich-component markers into a trusted template. */
248
+ const formatted = values ? formatMessage(template, neutralizeRichValues(values), locale) : template;
240
249
 
241
250
  /* Then parse rich text components */
242
251
  const parts = parseRichText(formatted, components);
@@ -1,4 +1,5 @@
1
- import { createMemo, createSignal, type JSX, splitProps } from "solid-js";
1
+ import { createMemo, createSignal, omit } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import { warn } from "../logger.ts";
3
4
 
4
5
  /* ── Static image data ── */
@@ -99,7 +100,7 @@ export interface StaticResponsiveImageProps extends FlareImgProps {
99
100
 
100
101
  export type ImageProps = FillImageProps | FixedImageProps | ResponsiveImageProps | StaticResponsiveImageProps;
101
102
 
102
- /* Flattened internal type for splitProps — avoids TS intersection issues with Omit */
103
+ /* Flattened internal type for omit — avoids TS intersection issues with Omit */
103
104
  interface ImagePropsInternal {
104
105
  alt: string;
105
106
  aspectRatio?: number;
@@ -326,7 +327,9 @@ function mergeStyles(
326
327
  /* ── Image component ── */
327
328
 
328
329
  export function Image(props: ImageProps): JSX.Element {
329
- const [local, rest] = splitProps(props as unknown as ImagePropsInternal, [
330
+ const local = props as unknown as ImagePropsInternal;
331
+ const rest = omit(
332
+ local,
330
333
  "alt",
331
334
  "aspectRatio",
332
335
  "blurDataURL",
@@ -345,7 +348,7 @@ export function Image(props: ImageProps): JSX.Element {
345
348
  "style",
346
349
  "width",
347
350
  "widths",
348
- ]);
351
+ );
349
352
 
350
353
  const [loaded, setLoaded] = createSignal(false);
351
354
 
@@ -1,4 +1,5 @@
1
- import { type JSX, Show, useContext } from "solid-js";
1
+ import { Show, useContext } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import { RouterContext } from "../outlet/index.tsx";
3
4
  import type { InterceptedState } from "../outlet/types.ts";
4
5
 
@@ -11,8 +12,11 @@ export interface InterceptOutletProps {
11
12
  /**
12
13
  * Renders intercepted route content. SSR-safe — returns null when outside
13
14
  * FlareProvider (root layout renders in NoHydration during SSR).
15
+ *
16
+ * Unkeyed on purpose: the function child receives an accessor. Reading it in
17
+ * JSX re-invokes `children` when intercept identity changes (overlay A → B).
14
18
  */
15
19
  export function InterceptOutlet(props: InterceptOutletProps): JSX.Element {
16
20
  const ctx = useContext(RouterContext);
17
- return (<Show when={ctx?.intercepted()}>{(state) => props.children(state())}</Show>) as JSX.Element;
21
+ return (<Show when={ctx?.intercepted()}>{(state) => <>{props.children(state())}</>}</Show>) as JSX.Element;
18
22
  }
@@ -1,7 +1,9 @@
1
- import type { JSX } from "solid-js";
2
- import { type Component, createSignal, onMount, Show, sharedConfig } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
+ import { Dynamic, isServer } from "@solidjs/web";
3
+ import { type Component, createSignal, onSettled, Show, sharedConfig, untrack } from "solid-js";
3
4
  import { retryImport } from "../internal.ts";
4
5
  import { warn } from "../logger.ts";
6
+ import { GLOBAL_LAZY_LOADED, GLOBAL_LAZY_PENDING } from "../protocol.ts";
5
7
 
6
8
  export interface LazyOptions<P extends Record<string, unknown>> {
7
9
  loader: () => Promise<{ default: Component<P> }>;
@@ -14,8 +16,8 @@ export interface ClientLazyOptions<P extends Record<string, unknown>> {
14
16
  pending?: Component<P>;
15
17
  }
16
18
 
17
- const PENDING_KEY = "__FLARE_LAZY_PENDING__";
18
- const LOADED_KEY = "__FLARE_LAZY_LOADED__";
19
+ const PENDING_KEY = GLOBAL_LAZY_PENDING;
20
+ const LOADED_KEY = GLOBAL_LAZY_LOADED;
19
21
 
20
22
  function getGlobalPending(): Set<Promise<void>> {
21
23
  const g = globalThis as Record<string, unknown>;
@@ -29,13 +31,9 @@ function getGlobalLoaded(): Set<Promise<void>> {
29
31
  return g[LOADED_KEY] as Set<Promise<void>>;
30
32
  }
31
33
 
32
- /**
33
- * Throws during component init — ErrorBoundary catches it.
34
- * Must be rendered via Show/conditional so the throw happens during
35
- * the component's initial render phase (not in an effect).
36
- */
34
+ /** Throw during render so `<Errored>` can catch. Read is untracked — error is a snapshot. */
37
35
  function ThrowError(props: { error: Error }): JSX.Element {
38
- throw props.error;
36
+ throw untrack(() => props.error);
39
37
  }
40
38
 
41
39
  export function lazy<P extends Record<string, unknown>>(options: LazyOptions<P>): Component<P> {
@@ -58,44 +56,42 @@ export function lazy<P extends Record<string, unknown>>(options: LazyOptions<P>)
58
56
  getGlobalPending().add(loadPromise);
59
57
 
60
58
  return ((props: P) => {
61
- const isSSR = !!sharedConfig.context;
59
+ const isSSR = isServer || !!sharedConfig.hydrating;
62
60
 
63
61
  /* Pre-known error at render time → throw from component body */
64
62
  if (loadError) throw loadError;
65
63
 
66
- const [component, setComponent] = createSignal<Component<P> | undefined>(isSSR ? undefined : loaded);
64
+ /* Store `{ C }` — Solid 2 treats a function initial value as a derived signal. */
65
+ const [component, setComponent] = createSignal<{ C: Component<P> } | undefined>(
66
+ isSSR || !loaded ? undefined : { C: loaded },
67
+ );
67
68
  const [error, setError] = createSignal<Error | undefined>();
68
69
 
69
70
  if (isSSR || !loaded) {
70
- onMount(() => {
71
+ onSettled(() => {
71
72
  if (loadError) {
72
73
  setError(loadError);
73
74
  } else if (loaded) {
74
- setComponent(() => loaded);
75
+ setComponent({ C: loaded });
75
76
  } else {
76
77
  loadPromise?.then(() => {
77
78
  if (loadError) setError(loadError);
78
- else setComponent(() => loaded);
79
+ else if (loaded) setComponent({ C: loaded });
79
80
  });
80
81
  }
81
82
  });
82
83
  }
83
84
 
84
- const PendingFallback = pending
85
- ? (() => {
86
- const P = pending;
87
- return (<P {...props} />) as JSX.Element;
88
- })()
89
- : null;
90
-
91
85
  return (
92
- <Show fallback={<ThrowError error={error() as Error} />} when={!error()}>
93
- <Show fallback={PendingFallback} when={component()}>
94
- {(Comp) => {
95
- const C = Comp();
96
- return (<C {...props} />) as JSX.Element;
97
- }}
98
- </Show>
86
+ <Show
87
+ fallback={
88
+ <Show fallback={pending ? <Dynamic component={pending} {...props} /> : null} when={component()}>
89
+ {(entry) => <Dynamic component={entry().C} {...props} />}
90
+ </Show>
91
+ }
92
+ when={error()}
93
+ >
94
+ {(err) => <ThrowError error={err() as Error} />}
99
95
  </Show>
100
96
  ) as JSX.Element;
101
97
  }) as Component<P>;
@@ -109,7 +105,7 @@ export function clientLazy<P extends Record<string, unknown>>(
109
105
  let loadError: Error | undefined;
110
106
  let loadPromise: Promise<void> | undefined;
111
107
 
112
- if (eager) {
108
+ if (eager && !isServer) {
113
109
  loadPromise = retryImport(loader)
114
110
  .then((mod) => {
115
111
  loaded = mod.default;
@@ -120,12 +116,15 @@ export function clientLazy<P extends Record<string, unknown>>(
120
116
  }
121
117
 
122
118
  return ((props: P & { pending?: Component<P> }) => {
123
- const isSSR = !!sharedConfig.context;
119
+ const isSSR = isServer || !!sharedConfig.hydrating;
124
120
 
125
121
  /* Pre-known error at render time → throw from component body */
126
122
  if (loadError) throw loadError;
127
123
 
128
- const [component, setComponent] = createSignal<Component<P> | undefined>(isSSR ? undefined : loaded);
124
+ /* Store `{ C }` — Solid 2 treats a function initial value as a derived signal. */
125
+ const [component, setComponent] = createSignal<{ C: Component<P> } | undefined>(
126
+ isSSR || !loaded ? undefined : { C: loaded },
127
+ );
129
128
  const [error, setError] = createSignal<Error | undefined>();
130
129
 
131
130
  const startLoading = () => {
@@ -141,7 +140,7 @@ export function clientLazy<P extends Record<string, unknown>>(
141
140
  loadPromise
142
141
  .then(() => {
143
142
  if (loadError) setError(loadError);
144
- else setComponent(() => loaded);
143
+ else if (loaded) setComponent({ C: loaded });
145
144
  })
146
145
  .catch((e: unknown) => {
147
146
  warn("lazy", "chunk retry failed", e);
@@ -156,9 +155,9 @@ export function clientLazy<P extends Record<string, unknown>>(
156
155
  /* Already loaded — render immediately */
157
156
  } else {
158
157
  /* SSR hydration or not-yet-loaded: kick off after mount */
159
- onMount(() => {
158
+ onSettled(() => {
160
159
  if (loaded) {
161
- setComponent(() => loaded);
160
+ setComponent({ C: loaded });
162
161
  } else if (loadError) {
163
162
  setError(loadError);
164
163
  } else {
@@ -167,22 +166,23 @@ export function clientLazy<P extends Record<string, unknown>>(
167
166
  });
168
167
  }
169
168
 
170
- const PendingComp = props.pending ?? factoryPending;
171
- const PendingFallback = PendingComp
172
- ? (() => {
173
- const P = PendingComp;
174
- return (<P {...(props as P)} />) as JSX.Element;
175
- })()
176
- : null;
177
-
178
169
  return (
179
- <Show fallback={<ThrowError error={error() as Error} />} when={!error()}>
180
- <Show fallback={PendingFallback} when={component()}>
181
- {(Comp) => {
182
- const C = Comp();
183
- return (<C {...(props as P)} />) as JSX.Element;
184
- }}
185
- </Show>
170
+ <Show
171
+ fallback={
172
+ <Show
173
+ fallback={
174
+ <Show when={props.pending ?? factoryPending}>
175
+ {(Comp) => <Dynamic component={Comp()} {...(props as P)} />}
176
+ </Show>
177
+ }
178
+ when={component()}
179
+ >
180
+ {(entry) => <Dynamic component={entry().C} {...(props as P)} />}
181
+ </Show>
182
+ }
183
+ when={error()}
184
+ >
185
+ {(err) => <ThrowError error={err() as Error} />}
186
186
  </Show>
187
187
  ) as JSX.Element;
188
188
  }) as Component<P & { pending?: Component<P> }>;