@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
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Flare
2
2
 
3
- Solid + Vite meta-framework. You declare pages and layouts with a typed builder. The server streams HTML (`renderToStream`). Later navigations speak NDJSON (`x-d: 1`). Prefetch, ISR, deferred loaders, and forms reuse that protocol.
3
+ Solid + Vite meta-framework. You declare pages and layouts with a typed builder. The server streams HTML (`renderToStream`). Later navigations speak NDJSON (`flare-data: 1`). Prefetch, ISR, deferred loaders, and forms reuse that protocol.
4
4
 
5
5
  This repo is the source of [`@lovrozagar/flare`](https://www.npmjs.com/package/@lovrozagar/flare) `0.1.1`. The CLI binary is `flare`.
6
6
 
@@ -123,7 +123,7 @@ npm add @lovrozagar/flare
123
123
  pnpm add @lovrozagar/flare
124
124
  ```
125
125
 
126
- Peers: `solid-js`, `vite`, `vite-plugin-solid`. Optional: `sharp` (images), `isbot` (skip locale cookies for bots), `@tanstack/solid-query` (query), `@tanstack/query-broadcast-client-experimental` (query broadcast), `oxc-parser`, `oxc-resolver`.
126
+ Peers: `solid-js`, `@solidjs/web`, `@solidjs/vite-plugin`, `vite`. Optional: `sharp` (images), `isbot` (skip locale cookies for bots), `@tanstack/solid-query` (query), `@tanstack/query-broadcast-client-experimental` (query broadcast), `oxc-parser`, `oxc-resolver`.
127
127
 
128
128
  The CLI is a workspace package (`@flare/cli`, binary `flare`). In this repo it is on the path via workspace linking. A published consumer uses `flare init` after adding the package.
129
129
 
@@ -132,7 +132,7 @@ The CLI is a workspace package (`@flare/cli`, binary `flare`). In this repo it i
132
132
  ```bash
133
133
  mkdir my-app && cd my-app
134
134
  bun init -y
135
- bun add @lovrozagar/flare solid-js vite vite-plugin-solid
135
+ bun add @lovrozagar/flare solid-js @solidjs/web vite @solidjs/vite-plugin
136
136
  flare init
137
137
  bun run dev
138
138
  ```
@@ -786,7 +786,7 @@ import { DirectionScript } from "@lovrozagar/flare/direction";
786
786
  </head>;
787
787
  ```
788
788
 
789
- - **Locale** — optional `[[locale]]` segment or prefix. Cookie `flare.locale` + `Accept-Language`. Default locale is stripped (`/en/about` → `/about`). Playwright / bot UAs skip Set-Cookie (`isbot`). Prefetch (`x-p: 1`) never writes the cookie.
789
+ - **Locale** — optional `[[locale]]` segment or prefix. Cookie `flare.locale` + `Accept-Language`. Default locale is stripped (`/en/about` → `/about`). Playwright / bot UAs skip Set-Cookie (`isbot`). Prefetch (`flare-prefetch: 1`) never writes the cookie.
790
790
  - **Theme** — `data-theme`, system preference, `localStorage`.
791
791
  - **Direction** — `dir` / `data-dir`.
792
792
 
@@ -908,7 +908,7 @@ import { BroadcastProvider } from "@lovrozagar/flare/broadcast";
908
908
  createRouter({ queryClientGetter: getQueryClient });
909
909
  ```
910
910
 
911
- SSR dehydrates into the stream (`__flare_qc` / `t:"q"`). `useSuspenseQuery` suspends until the dehydrated entry is ready. Optional `BroadcastProvider` invalidates across tabs.
911
+ SSR dehydrates into the stream (`__flare_queries` / `t:"q"`). `useSuspenseQuery` suspends until the dehydrated entry is ready. Optional `BroadcastProvider` invalidates across tabs.
912
912
 
913
913
  ```ts
914
914
  import { createQueryClientGetter } from "@lovrozagar/flare/query-client";
@@ -993,7 +993,28 @@ Playwright helpers: hydration, FlareState shape, console-error filters. Product
993
993
 
994
994
  ## NDJSON protocol
995
995
 
996
- SPA / prefetch / data requests send `x-d: 1`. Prefetch also sends `x-p: 1`. Stale match skip uses `x-m`.
996
+ SPA / prefetch / data requests send `flare-data: 1`. Prefetch also sends `flare-prefetch: 1`. Stale match skip uses `flare-stale`. Flag value is always `"1"`. Headers are lowercase (HTTP/2 / Node). NDJSON `t` codes and FlareState keys stay short.
997
+
998
+ | Request | Meaning |
999
+ | ----------------- | ------------------------------------------------- |
1000
+ | `flare-data` | NDJSON data request. `Vary` always includes this. |
1001
+ | `flare-prefetch` | Prefetch (no locale cookie commit) |
1002
+ | `flare-stale` | Comma-separated match ids the client already has |
1003
+ | `flare-isr` | Internal ISR background re-render |
1004
+ | `flare-prerender` | Build-time prerender fetch |
1005
+
1006
+ | Response | Meaning |
1007
+ | -------------- | ------------------------ |
1008
+ | `flare-cache` | `HIT` / `MISS` / `STALE` |
1009
+ | `flare-render` | `ISR` / `SSG` / `SSR` |
1010
+
1011
+ | Path / HTML | Meaning |
1012
+ | ------------------------------- | ------------------------------------------ |
1013
+ | `/_flare/server-fn/{id}/{name}` | Server functions |
1014
+ | `flare_fn` | Hidden form field for no-JS POST |
1015
+ | `data-flare-hydrated` | Set on `<html>` after `solidHydrate` |
1016
+ | `__flare_defer` | Window queue for SSR deferred chunks |
1017
+ | `__flare_queries` | Window queue for SSR query-cache dehydrate |
997
1018
 
998
1019
  | `t` | Meaning |
999
1020
  | --- | --------------- |
@@ -1053,7 +1074,7 @@ export default defineConfig({
1053
1074
  | `ignorePrefix` | extra ignored folder names | |
1054
1075
  | `port` | Vite `server.port` (overrides CLI `--port`) | |
1055
1076
  | `alias` | Vite alias | |
1056
- | `solid` | passed to `vite-plugin-solid` | |
1077
+ | `solid` | passed to `@solidjs/vite-plugin` | `ssr: true` is forced; `start: true` is rejected |
1057
1078
 
1058
1079
  `dev: false` turns every `dev.*` flag off. Dev dashboard is `@node-only` in e2e (not on Workers).
1059
1080
 
@@ -1141,7 +1162,7 @@ Requires [Bun](https://bun.sh) 1.3+ and TypeScript 7.
1141
1162
  bun install
1142
1163
  bun run test # core unit
1143
1164
  bun run test:cli # CLI unit
1144
- bun run test:all # unit + all e2e apps on node
1165
+ bun run test:all # unit + build + e2e apps on node (dev then prod)
1145
1166
  bun run test:all -- --env bun
1146
1167
  bun run typecheck
1147
1168
  bun run typecheck:consumers
@@ -1149,7 +1170,7 @@ bun run lint
1149
1170
  bun run fmt:check
1150
1171
  ```
1151
1172
 
1152
- GitHub Actions (`.github/workflows/ci.yml`): `test` runs typecheck, fmt, lint, unit. `e2e` is a matrix of node / bun / workers / deno.
1173
+ GitHub Actions (`.github/workflows/ci.yml`): `test` runs typecheck, fmt, lint, unit. `e2e` is a matrix of node / bun / workers / deno, each in Vite **dev** and (except deno/firefox) **prod** (`vite preview`).
1153
1174
 
1154
1175
  Release: bump `packages/core/package.json`, commit, tag `vX.Y.Z` matching that version, and push the tag. `.github/workflows/release.yml` publishes `@lovrozagar/flare` to npm via GitHub Actions OIDC (no npm token) and opens the GitHub Release. The npm trusted publisher must name this file `release.yml`.
1155
1176
 
@@ -1172,16 +1193,18 @@ Do not weaken `strict` or add `as any` to make typecheck pass.
1172
1193
 
1173
1194
  ### Test matrix
1174
1195
 
1175
- | Command | What it proves |
1176
- | -------------------------- | ------------------------------ |
1177
- | `bun run test` | Core unit + integration |
1178
- | `bun run test:cli` | CLI unit |
1179
- | `bun run test:e2e` | Playwright, every app × node |
1180
- | `bun run test:e2e:bun` | Same tests, Bun listen |
1181
- | `bun run test:e2e:workers` | Same tests, local workerd |
1182
- | `bun run test:e2e:deno` | Same tests, Deno |
1183
- | `bun run test:e2e:firefox` | Same tests, Firefox (dev only) |
1184
- | `bun run test:e2e:prod` | Node, `TEST_MODE=prod` |
1196
+ | Command | What it proves |
1197
+ | ------------------------------- | ------------------------------------------ |
1198
+ | `bun run test` | Core unit + integration |
1199
+ | `bun run test:cli` | CLI unit |
1200
+ | `bun run test:e2e` | Playwright, every app × node, Vite **dev** |
1201
+ | `bun run test:e2e:prod` | Same tests, `vite build` + **preview** |
1202
+ | `bun run test:e2e:bun` | Same tests, Bun listen (dev) |
1203
+ | `bun run test:e2e:bun:prod` | Bun listen, prod preview |
1204
+ | `bun run test:e2e:workers` | Same tests, local workerd (dev) |
1205
+ | `bun run test:e2e:workers:prod` | workerd, prod preview |
1206
+ | `bun run test:e2e:deno` | Same tests, Deno (dev) |
1207
+ | `bun run test:e2e:firefox` | Same tests, Firefox (**dev only**) |
1185
1208
 
1186
1209
  ```bash
1187
1210
  bun run test:e2e -- --app product
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -107,6 +107,8 @@
107
107
  "@capsizecss/metrics": "catalog:",
108
108
  "@formatjs/intl-localematcher": "catalog:",
109
109
  "@solidjs/testing-library": "catalog:",
110
+ "@solidjs/vite-plugin": "catalog:",
111
+ "@solidjs/web": "catalog:",
110
112
  "@tanstack/query-broadcast-client-experimental": "catalog:",
111
113
  "@tanstack/solid-query": "catalog:",
112
114
  "@types/negotiator": "catalog:",
@@ -121,14 +123,15 @@
121
123
  "solid-js": "catalog:",
122
124
  "typescript": "catalog:",
123
125
  "vite": "catalog:",
124
- "vite-plugin-solid": "catalog:",
125
126
  "vitest": "catalog:"
126
127
  },
127
128
  "peerDependencies": {
128
129
  "@babel/parser": ">=8.0.0",
129
130
  "@formatjs/intl-localematcher": ">=0.6.0",
131
+ "@solidjs/vite-plugin": ">=3.0.0-next.0",
132
+ "@solidjs/web": ">=2.0.0-rc.0",
130
133
  "@tanstack/query-broadcast-client-experimental": ">=5.0.0",
131
- "@tanstack/solid-query": ">=5.0.0",
134
+ "@tanstack/solid-query": ">=6.0.0-rc.0",
132
135
  "isbot": ">=5.0.0",
133
136
  "magic-string": ">=0.30.0",
134
137
  "negotiator": ">=1.0.0",
@@ -136,9 +139,8 @@
136
139
  "oxc-parser": ">=0.139.0",
137
140
  "oxc-resolver": ">=11.23.0",
138
141
  "sharp": ">=0.33.0",
139
- "solid-js": ">=1.9.0",
140
- "vite": ">=8.0.0",
141
- "vite-plugin-solid": ">=2.0.0"
142
+ "solid-js": ">=2.0.0-rc.0",
143
+ "vite": ">=8.0.0"
142
144
  },
143
145
  "peerDependenciesMeta": {
144
146
  "@formatjs/intl-localematcher": {
@@ -42,7 +42,7 @@ export function createBroadcastSignal<T>(key: string, initialValue: T): [() => T
42
42
 
43
43
  let entry = registry.get(key);
44
44
  if (!entry) {
45
- const [value, setValue] = createSignal<T>(initialValue);
45
+ const [value, setValue] = createSignal(initialValue as Exclude<T, Function>);
46
46
  entry = {
47
47
  refCount: 0,
48
48
  signal: [value, setValue as (v: unknown) => void],
@@ -1,7 +1,7 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import type { InternalChannel } from "./channel.ts";
3
3
  import { BroadcastCtx, noopChannel } from "./context.ts";
4
4
 
5
5
  export function BroadcastProvider(props: { children: JSX.Element; value?: InternalChannel }): JSX.Element {
6
- return <BroadcastCtx.Provider value={props.value ?? noopChannel}>{props.children}</BroadcastCtx.Provider>;
6
+ return <BroadcastCtx value={props.value ?? noopChannel}>{props.children}</BroadcastCtx>;
7
7
  }
@@ -1,8 +1,34 @@
1
1
  import type { HeadConfig } from "../route-builder/types.ts";
2
2
 
3
+ export function resolveCacheTags(
4
+ cache:
5
+ | {
6
+ cdn?: { tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]) } | false;
7
+ ssr?: { tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]) } | false;
8
+ }
9
+ | undefined,
10
+ params: Record<string, string | string[]>,
11
+ ): string[] | undefined {
12
+ if (!cache) return undefined;
13
+ const tags: string[] = [];
14
+ const push = (
15
+ src: { tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]) } | false | undefined,
16
+ ) => {
17
+ if (!src || !src.tags) return;
18
+ const list = typeof src.tags === "function" ? src.tags({ params }) : src.tags;
19
+ for (const t of list) {
20
+ if (typeof t === "string" && t.length > 0 && !tags.includes(t)) tags.push(t);
21
+ }
22
+ };
23
+ push(cache.cdn);
24
+ push(cache.ssr);
25
+ return tags.length > 0 ? tags : undefined;
26
+ }
27
+
3
28
  export interface CachedMatch {
4
29
  data: unknown;
5
30
  error?: unknown;
31
+ gcTime?: number;
6
32
  hasDeferred?: boolean;
7
33
  headConfig?: HeadConfig;
8
34
  invalid: boolean;
@@ -1,4 +1,5 @@
1
- import { batch, createEffect, createSignal, type JSX } from "solid-js";
1
+ import { createEffect, createMemo, createSignal, untrack } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
 
3
4
  export interface Deferred<T> {
4
5
  __deferred: true;
@@ -47,8 +48,10 @@ export function getResolvedError(value: unknown): Error | undefined {
47
48
  }
48
49
 
49
50
  export function Await<T>(props: AwaitProps<T>): JSX.Element {
50
- const initialResolved = getResolvedValue(props.promise);
51
- const initialError = getResolvedError(props.promise);
51
+ /* Snapshot only — `createEffect` below tracks `props.promise` for updates. */
52
+ const initialPromise = untrack(() => props.promise);
53
+ const initialResolved = getResolvedValue(initialPromise);
54
+ const initialError = getResolvedError(initialPromise);
52
55
 
53
56
  function computeInitialStatus(): AwaitStatus {
54
57
  if (initialResolved !== undefined) return "success";
@@ -57,10 +60,10 @@ export function Await<T>(props: AwaitProps<T>): JSX.Element {
57
60
  }
58
61
 
59
62
  const [status, setStatus] = createSignal<AwaitStatus>(computeInitialStatus());
60
- const [data, setData] = createSignal<T | undefined>(initialResolved);
63
+ const [data, setData] = createSignal<T | undefined>(initialResolved as Exclude<T | undefined, Function>);
61
64
  const [error, setError] = createSignal<Error | undefined>(initialError);
62
65
 
63
- let currentPromise = getPromise(props.promise);
66
+ let currentPromise = getPromise(initialPromise);
64
67
 
65
68
  if (initialResolved === undefined && !initialError && currentPromise) {
66
69
  trackPromise(currentPromise);
@@ -70,18 +73,14 @@ export function Await<T>(props: AwaitProps<T>): JSX.Element {
70
73
  p.then(
71
74
  (result) => {
72
75
  if (currentPromise === p) {
73
- batch(() => {
74
- setData(() => result);
75
- setStatus("success");
76
- });
76
+ setData(() => result);
77
+ setStatus("success");
77
78
  }
78
79
  },
79
80
  (err: unknown) => {
80
81
  if (currentPromise === p) {
81
- batch(() => {
82
- setError(() => (err instanceof Error ? err : new Error(String(err))));
83
- setStatus("error");
84
- });
82
+ setError(() => (err instanceof Error ? err : new Error(String(err))));
83
+ setStatus("error");
85
84
  }
86
85
  },
87
86
  );
@@ -90,43 +89,41 @@ export function Await<T>(props: AwaitProps<T>): JSX.Element {
90
89
  function reset(): void {
91
90
  const p = getPromise(props.promise);
92
91
  currentPromise = p;
93
- batch(() => {
94
- setStatus("pending");
95
- setData(undefined);
96
- setError(undefined);
97
- });
92
+ setStatus("pending");
93
+ setData(undefined);
94
+ setError(undefined);
98
95
  if (p) trackPromise(p);
99
96
  }
100
97
 
101
- /* Watch for promise prop changes */
102
- createEffect(() => {
103
- const newPromise = getPromise(props.promise);
104
- if (newPromise === currentPromise) return;
105
- currentPromise = newPromise;
98
+ /* Watch for promise prop changes. Wrap in an object so a Promise value is
99
+ not treated as an async computation (Solid 2 unwraps returned Promises). */
100
+ createEffect(
101
+ () => ({ promise: props.promise }),
102
+ ({ promise }) => {
103
+ const newPromise = getPromise(promise);
104
+ if (newPromise === currentPromise) return;
105
+ currentPromise = newPromise;
106
106
 
107
- const resolved = getResolvedValue(props.promise);
108
- const resolvedError = getResolvedError(props.promise);
107
+ const resolved = getResolvedValue(promise);
108
+ const resolvedError = getResolvedError(promise);
109
109
 
110
- if (resolved !== undefined) {
111
- batch(() => {
110
+ if (resolved !== undefined) {
112
111
  setData(() => resolved);
113
112
  setStatus("success");
114
- });
115
- return;
116
- }
117
- if (resolvedError) {
118
- batch(() => {
113
+ return;
114
+ }
115
+ if (resolvedError) {
119
116
  setError(() => resolvedError);
120
117
  setStatus("error");
121
- });
122
- return;
123
- }
118
+ return;
119
+ }
124
120
 
125
- setStatus("pending");
126
- if (newPromise) trackPromise(newPromise);
127
- });
121
+ setStatus("pending");
122
+ if (newPromise) trackPromise(newPromise);
123
+ },
124
+ );
128
125
 
129
- return (() => {
126
+ return createMemo(() => {
130
127
  const s = status();
131
128
  if (s === "success") {
132
129
  return props.children(data() as T);
@@ -1,5 +1,6 @@
1
- import { createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
2
- import { Portal } from "solid-js/web";
1
+ import { createSignal, For, onSettled, Show } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import { Portal } from "@solidjs/web";
3
4
  import { createDevErrorStore, type DevErrorStore } from "./index.ts";
4
5
 
5
6
  let globalStore: DevErrorStore | undefined;
@@ -34,16 +35,14 @@ export function DevErrorOverlay(): JSX.Element {
34
35
  refresh();
35
36
  }
36
37
 
37
- onMount(() => {
38
+ onSettled(() => {
38
39
  if (typeof window === "undefined") return;
39
40
  window.addEventListener("error", handleWindowError);
40
41
  window.addEventListener("unhandledrejection", handleUnhandledRejection);
41
- });
42
-
43
- onCleanup(() => {
44
- if (typeof window === "undefined") return;
45
- window.removeEventListener("error", handleWindowError);
46
- window.removeEventListener("unhandledrejection", handleUnhandledRejection);
42
+ return () => {
43
+ window.removeEventListener("error", handleWindowError);
44
+ window.removeEventListener("unhandledrejection", handleUnhandledRejection);
45
+ };
47
46
  });
48
47
 
49
48
  function dismiss(id: string): void {
@@ -1,5 +1,5 @@
1
- import type { JSX } from "solid-js";
2
- import { NoHydration } from "solid-js/web";
1
+ import type { JSX } from "@solidjs/web";
2
+ import { NoHydration } from "@solidjs/web";
3
3
  import type { DirectionConfig } from "../direction.ts";
4
4
  import { getDirectionScript } from "../direction.ts";
5
5
  import { useSSRContext } from "./ssr-context.tsx";
@@ -1,5 +1,5 @@
1
- import type { JSX } from "solid-js";
2
- import { NoHydration } from "solid-js/web";
1
+ import type { JSX } from "@solidjs/web";
2
+ import { NoHydration } from "@solidjs/web";
3
3
  import type { LocaleConfig } from "../locale/index.tsx";
4
4
  import { getLocaleScript } from "../locale/index.tsx";
5
5
  import { useSSRContext } from "./ssr-context.tsx";
@@ -1,4 +1,4 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import { useSSRContext } from "./ssr-context.tsx";
3
3
 
4
4
  /* Duplicated from index.ts to avoid circular import (index re-exports this component) */
@@ -1,4 +1,5 @@
1
- import { createContext, type JSX, sharedConfig, useContext } from "solid-js";
1
+ import { createContext, useContext } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import type { DirectionConfig } from "../direction.ts";
3
4
  import type { LocaleConfig } from "../locale/index.tsx";
4
5
  import type { HeadConfig } from "../route-builder/types.ts";
@@ -15,23 +16,15 @@ export interface SSRContextValue {
15
16
  theme?: ThemeConfig;
16
17
  }
17
18
 
18
- const SSRCtx = createContext<SSRContextValue>();
19
+ const SSRCtx = createContext<SSRContextValue | null>(null);
19
20
 
20
- export function setSSRContext(value: SSRContextValue): void {
21
- if (sharedConfig.context) {
22
- (sharedConfig.context as Record<string, unknown>).flare = value;
23
- }
24
- }
21
+ /** @deprecated Wrap the tree with SSRContextProvider. Solid 2 has no sharedConfig.context. */
22
+ export function setSSRContext(_value: SSRContextValue): void {}
25
23
 
26
24
  export function useSSRContext(): SSRContextValue | undefined {
27
- /* SSR path: read from sharedConfig.context */
28
- if (sharedConfig.context) {
29
- return (sharedConfig.context as Record<string, unknown>).flare as SSRContextValue | undefined;
30
- }
31
- /* Testing fallback: read from Solid context provider */
32
- return useContext(SSRCtx);
25
+ return useContext(SSRCtx) ?? undefined;
33
26
  }
34
27
 
35
28
  export function SSRContextProvider(props: { children: JSX.Element; value: SSRContextValue }): JSX.Element {
36
- return <SSRCtx.Provider value={props.value}>{props.children}</SSRCtx.Provider>;
29
+ return <SSRCtx value={props.value}>{props.children}</SSRCtx>;
37
30
  }
@@ -1,5 +1,5 @@
1
- import type { JSX } from "solid-js";
2
- import { NoHydration } from "solid-js/web";
1
+ import type { JSX } from "@solidjs/web";
2
+ import { NoHydration } from "@solidjs/web";
3
3
  import type { ThemeConfig } from "../theme.ts";
4
4
  import { getThemeScript } from "../theme.ts";
5
5
  import { useSSRContext } from "./ssr-context.tsx";
@@ -1,4 +1,4 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import { useSSRContext } from "./ssr-context.tsx";
3
3
 
4
4
  /* Duplicated from index.ts to avoid circular import (index re-exports this component) */
@@ -80,3 +80,25 @@ export function isDeferred(value: unknown): value is Deferred<unknown> {
80
80
  (value as Record<string, unknown>).__deferred === true
81
81
  );
82
82
  }
83
+
84
+ /** True if `data` is or contains a deferred marker. Used to skip SSR store cache of unresolved streams. */
85
+ export function containsDeferred(data: unknown, seen?: WeakSet<object>): boolean {
86
+ if (data === null || data === undefined || typeof data !== "object") return false;
87
+ if (isDeferred(data)) return true;
88
+
89
+ const tracking = seen ?? new WeakSet<object>();
90
+ if (tracking.has(data as object)) return false;
91
+ tracking.add(data as object);
92
+
93
+ if (Array.isArray(data)) {
94
+ for (const item of data) {
95
+ if (containsDeferred(item, tracking)) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ for (const value of Object.values(data as Record<string, unknown>)) {
101
+ if (containsDeferred(value, tracking)) return true;
102
+ }
103
+ return false;
104
+ }
@@ -1,14 +1,5 @@
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, sharedConfig, untrack, useContext } from "solid-js";
2
+ import { isServer, type JSX } from "@solidjs/web";
12
3
 
13
4
  export type Direction = "ltr" | "rtl";
14
5
 
@@ -60,14 +51,9 @@ export interface DirectionContextValue {
60
51
  toggleDirection: () => void;
61
52
  }
62
53
 
63
- const DirectionCtx = createContext<DirectionContextValue>();
54
+ const DirectionCtx = createContext<DirectionContextValue | null>(null);
64
55
 
65
56
  export function DirectionProvider(props: { children: JSX.Element; config?: DirectionConfig }): JSX.Element {
66
- /* SSR: pass through children (context not needed on server, scripts handle direction) */
67
- if (sharedConfig.context) {
68
- return props.children;
69
- }
70
-
71
57
  const cfg: Required<DirectionConfig> = {
72
58
  attribute: props.config?.attribute ?? DEFAULT_CONFIG.attribute,
73
59
  defaultDir: props.config?.defaultDir ?? DEFAULT_CONFIG.defaultDir,
@@ -75,9 +61,12 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
75
61
  storageKey: props.config?.storageKey ?? DEFAULT_CONFIG.storageKey,
76
62
  };
77
63
 
78
- /* Read initial direction from localStorage, DOM dir attr, or default */
64
+ const hydrating = isServer || !!sharedConfig.hydrating;
65
+
66
+ /* During SSR + hydration, start from defaultDir so the tree matches.
67
+ DirectionScript already applied localStorage to <html> before first paint. */
79
68
  let initial: Direction = cfg.defaultDir;
80
- if (typeof localStorage !== "undefined") {
69
+ if (typeof localStorage !== "undefined" && !hydrating) {
81
70
  try {
82
71
  const stored = localStorage.getItem(cfg.storageKey);
83
72
  if (stored === "ltr" || stored === "rtl") {
@@ -87,7 +76,7 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
87
76
  /* noop */
88
77
  }
89
78
  }
90
- if (initial === cfg.defaultDir && typeof document !== "undefined") {
79
+ if (initial === cfg.defaultDir && typeof document !== "undefined" && !hydrating) {
91
80
  const domDir = document.documentElement.getAttribute("dir");
92
81
  if (domDir === "ltr" || domDir === "rtl") {
93
82
  initial = domDir;
@@ -96,17 +85,34 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
96
85
 
97
86
  const [direction, setDirectionSignal] = createSignal<Direction>(initial);
98
87
 
99
- /* Sync direction to DOM */
100
- createEffect(() => {
101
- const dir = direction();
88
+ const applyToDocument = (dir: Direction): void => {
102
89
  if (typeof document === "undefined") return;
103
90
  const el = document.documentElement;
104
91
  el.setAttribute(cfg.attribute, dir);
105
92
  el.setAttribute("dir", dir);
93
+ };
94
+
95
+ if (!hydrating) {
96
+ applyToDocument(untrack(direction));
97
+ }
98
+
99
+ /* After hydrate, sync from localStorage so useDirection() matches DirectionScript. */
100
+ onSettled(() => {
101
+ if (typeof localStorage === "undefined") return;
102
+ try {
103
+ const stored = localStorage.getItem(cfg.storageKey);
104
+ if ((stored === "ltr" || stored === "rtl") && stored !== untrack(direction)) {
105
+ setDirectionSignal(stored);
106
+ }
107
+ } catch {
108
+ /* noop */
109
+ }
110
+ applyToDocument(untrack(direction));
106
111
  });
107
112
 
108
113
  /* Cross-tab sync: StorageEvent fires in OTHER tabs only */
109
- onMount(() => {
114
+ onSettled(() => {
115
+ if (typeof window === "undefined") return;
110
116
  const handler = (e: StorageEvent) => {
111
117
  if (e.key !== cfg.storageKey || e.storageArea !== localStorage) return;
112
118
  const v = e.newValue;
@@ -115,22 +121,41 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
115
121
  }
116
122
  };
117
123
  window.addEventListener("storage", handler);
118
- onCleanup(() => window.removeEventListener("storage", handler));
124
+ return () => window.removeEventListener("storage", handler);
119
125
  });
120
126
 
121
- /* Persist to localStorage on changes */
122
- createEffect(
123
- on(
124
- direction,
125
- (dir) => {
127
+ /* After Solid hydrates <html> it may drop DirectionScript's dir. Re-apply. */
128
+ let skipFirst = true;
129
+ createEffect(direction, (dir) => {
130
+ if (typeof document === "undefined") return;
131
+ if (skipFirst) {
132
+ skipFirst = false;
133
+ let visual: Direction = dir;
134
+ if (typeof localStorage !== "undefined") {
126
135
  try {
127
- localStorage.setItem(cfg.storageKey, dir);
136
+ const stored = localStorage.getItem(cfg.storageKey);
137
+ if (stored === "ltr" || stored === "rtl") visual = stored;
128
138
  } catch {
129
139
  /* noop */
130
140
  }
131
- },
132
- { defer: true },
133
- ),
141
+ }
142
+ applyToDocument(visual);
143
+ return;
144
+ }
145
+ applyToDocument(dir);
146
+ });
147
+
148
+ /* Persist to localStorage on changes (not initial — it was read from there) */
149
+ createEffect(
150
+ direction,
151
+ (dir) => {
152
+ try {
153
+ localStorage.setItem(cfg.storageKey, dir);
154
+ } catch {
155
+ /* noop */
156
+ }
157
+ },
158
+ { defer: true },
134
159
  );
135
160
 
136
161
  const setDirection = (dir: Direction): void => {
@@ -138,7 +163,7 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
138
163
  };
139
164
 
140
165
  const toggleDirection = (): void => {
141
- const current = direction();
166
+ const current = untrack(direction);
142
167
  setDirection(current === "ltr" ? "rtl" : "ltr");
143
168
  };
144
169
 
@@ -153,7 +178,7 @@ export function DirectionProvider(props: { children: JSX.Element; config?: Direc
153
178
  toggleDirection,
154
179
  };
155
180
 
156
- return <DirectionCtx.Provider value={value}>{props.children}</DirectionCtx.Provider>;
181
+ return <DirectionCtx value={value}>{props.children}</DirectionCtx>;
157
182
  }
158
183
 
159
184
  export function useDirection(): DirectionContextValue {