@real-router/solid 0.3.1 → 0.4.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 (37) hide show
  1. package/README.md +11 -0
  2. package/dist/cjs/index.js +168 -65
  3. package/dist/esm/index.mjs +169 -66
  4. package/dist/types/RouterProvider.d.ts +1 -0
  5. package/dist/types/RouterProvider.d.ts.map +1 -1
  6. package/dist/types/components/Link.d.ts.map +1 -1
  7. package/dist/types/components/RouteView/components.d.ts.map +1 -1
  8. package/dist/types/components/RouteView/helpers.d.ts +2 -4
  9. package/dist/types/components/RouteView/helpers.d.ts.map +1 -1
  10. package/dist/types/components/RouterErrorBoundary.d.ts.map +1 -1
  11. package/dist/types/createSignalFromSource.d.ts.map +1 -1
  12. package/dist/types/createStoreFromSource.d.ts.map +1 -1
  13. package/dist/types/dom-utils/index.d.ts +1 -1
  14. package/dist/types/dom-utils/index.d.ts.map +1 -1
  15. package/dist/types/dom-utils/link-utils.d.ts +2 -1
  16. package/dist/types/dom-utils/link-utils.d.ts.map +1 -1
  17. package/dist/types/dom-utils/route-announcer.d.ts.map +1 -1
  18. package/dist/types/hooks/sharedNodeSource.d.ts +4 -0
  19. package/dist/types/hooks/sharedNodeSource.d.ts.map +1 -0
  20. package/dist/types/hooks/useRouteNode.d.ts.map +1 -1
  21. package/dist/types/hooks/useRouteNodeStore.d.ts.map +1 -1
  22. package/dist/types/hooks/useRouteStore.d.ts.map +1 -1
  23. package/dist/types/hooks/useRouterTransition.d.ts.map +1 -1
  24. package/package.json +4 -4
  25. package/src/RouterProvider.tsx +1 -1
  26. package/src/components/Link.tsx +51 -8
  27. package/src/components/RouteView/RouteView.tsx +13 -13
  28. package/src/components/RouteView/components.tsx +12 -6
  29. package/src/components/RouteView/helpers.tsx +19 -17
  30. package/src/components/RouterErrorBoundary.tsx +4 -6
  31. package/src/createSignalFromSource.ts +9 -1
  32. package/src/createStoreFromSource.ts +1 -3
  33. package/src/hooks/sharedNodeSource.ts +30 -0
  34. package/src/hooks/useRouteNode.tsx +2 -4
  35. package/src/hooks/useRouteNodeStore.tsx +2 -3
  36. package/src/hooks/useRouteStore.tsx +0 -8
  37. package/src/hooks/useRouterTransition.tsx +15 -3
package/README.md CHANGED
@@ -90,6 +90,17 @@ function UserProfile() {
90
90
 
91
91
  Signal-based hooks (`useRoute`, `useRouteNode`) remain available for simpler use cases.
92
92
 
93
+ ### Primitives
94
+
95
+ Two low-level bridges convert `@real-router/sources` `RouterSource<T>` instances into Solid reactive primitives. Use them when you build custom hooks on top of `@real-router/sources`:
96
+
97
+ | Primitive | Returns | Description |
98
+ | ------------------------ | ------------------ | ------------------------------------------------------------- |
99
+ | `createSignalFromSource` | `Accessor<T>` | Bridges a source to a Solid signal. Calls `onCleanup`. |
100
+ | `createStoreFromSource` | `T` (Solid store) | Bridges a source to a Solid store via `createStore + reconcile`. |
101
+
102
+ Both must be called inside a reactive owner (component body or `createRoot`).
103
+
93
104
  ```tsx
94
105
  // useRouteNode — updates only when "users.*" changes
95
106
  function UsersLayout() {
package/dist/cjs/index.js CHANGED
@@ -8,8 +8,10 @@ var routeUtils = require('@real-router/route-utils');
8
8
  var store = require('solid-js/store');
9
9
  var core = require('@real-router/core');
10
10
 
11
- const MATCH_MARKER = Symbol.for("RouteView.Match");
12
- const NOT_FOUND_MARKER = Symbol.for("RouteView.NotFound");
11
+ // Local (non-global) Symbols — Symbol.for() would expose markers to spoofing
12
+ // via the global Symbol registry. See Gotchas section "RouteView Marker Objects".
13
+ const MATCH_MARKER = Symbol("RouteView.Match");
14
+ const NOT_FOUND_MARKER = Symbol("RouteView.NotFound");
13
15
  function Match(props) {
14
16
  const result = {
15
17
  $$type: MATCH_MARKER,
@@ -20,6 +22,10 @@ function Match(props) {
20
22
  return props.children;
21
23
  }
22
24
  };
25
+
26
+ // Marker object is identified by $$type Symbol in RouteView/helpers.tsx,
27
+ // not rendered as JSX. Cast required because JSX.Element does not include
28
+ // arbitrary marker shapes.
23
29
  return result;
24
30
  }
25
31
  Match.displayName = "RouteView.Match";
@@ -30,6 +36,8 @@ function NotFound(props) {
30
36
  return props.children;
31
37
  }
32
38
  };
39
+
40
+ // See Match for the marker-pattern rationale.
33
41
  return result;
34
42
  }
35
43
  NotFound.displayName = "RouteView.NotFound";
@@ -69,43 +77,65 @@ function buildRenderList(elements, routeName, nodeName) {
69
77
  notFoundChildren = child.children;
70
78
  continue;
71
79
  }
80
+ if (activeMatchFound) {
81
+ continue;
82
+ }
72
83
  const {
73
84
  segment,
74
85
  exact,
75
86
  fallback
76
87
  } = child;
77
88
  const fullSegmentName = nodeName ? `${nodeName}.${segment}` : segment;
78
- const isActive = !activeMatchFound && isSegmentMatch(routeName, fullSegmentName, exact);
79
- if (isActive) {
80
- activeMatchFound = true;
81
- const matchContent = child.children;
82
- const content = fallback === undefined ? matchContent : web.createComponent(solidJs.Suspense, {
83
- fallback: fallback,
84
- children: matchContent
85
- });
86
- rendered.push(content);
89
+ if (!isSegmentMatch(routeName, fullSegmentName, exact)) {
90
+ continue;
87
91
  }
92
+ activeMatchFound = true;
93
+ rendered.push(fallback === undefined ? child.children : web.createComponent(solidJs.Suspense, {
94
+ fallback: fallback,
95
+ get children() {
96
+ return child.children;
97
+ }
98
+ }));
88
99
  }
89
100
  if (!activeMatchFound && routeName === core.UNKNOWN_ROUTE && notFoundChildren !== null) {
90
101
  rendered.push(notFoundChildren);
91
102
  }
92
- return {
93
- rendered,
94
- activeMatchFound
95
- };
103
+ return rendered;
96
104
  }
97
105
 
98
106
  function createSignalFromSource(source) {
99
107
  const [value, setValue] = solidJs.createSignal(source.getSnapshot());
108
+ const sync = () => source.getSnapshot();
100
109
  const unsubscribe = source.subscribe(() => {
101
- setValue(() => source.getSnapshot());
110
+ setValue(sync);
102
111
  });
112
+
113
+ // Re-read after subscribe: lazy sources reconcile their snapshot in
114
+ // onFirstSubscribe (when reused after disconnect via cache). Listener is not
115
+ // notified for that internal update, so we must sync the signal manually.
116
+ // No-op when snapshot is unchanged (signal equality check).
117
+ setValue(sync);
103
118
  solidJs.onCleanup(() => {
104
119
  unsubscribe();
105
120
  });
106
121
  return value;
107
122
  }
108
123
 
124
+ const cache$2 = new WeakMap();
125
+ function getOrCreateNodeSource(router, nodeName) {
126
+ let perRouter = cache$2.get(router);
127
+ if (!perRouter) {
128
+ perRouter = new Map();
129
+ cache$2.set(router, perRouter);
130
+ }
131
+ let source = perRouter.get(nodeName);
132
+ if (!source) {
133
+ source = sources.createRouteNodeSource(router, nodeName);
134
+ perRouter.set(nodeName, source);
135
+ }
136
+ return source;
137
+ }
138
+
109
139
  const RouterContext = solidJs.createContext(null);
110
140
  const RouteContext = solidJs.createContext(null);
111
141
 
@@ -119,27 +149,24 @@ const useRouter = () => {
119
149
 
120
150
  function useRouteNode(nodeName) {
121
151
  const router = useRouter();
122
- const store = sources.createRouteNodeSource(router, nodeName);
123
- return createSignalFromSource(store);
152
+ return createSignalFromSource(getOrCreateNodeSource(router, nodeName));
124
153
  }
125
154
 
126
155
  function RouteViewRoot(props) {
127
156
  const routeState = useRouteNode(props.nodeName);
128
157
  const resolved = solidJs.children(() => props.children);
158
+ const elements = solidJs.createMemo(() => {
159
+ const arr = [];
160
+ collectElements(resolved(), arr);
161
+ return arr;
162
+ });
129
163
  return web.memo(() => {
130
164
  const state = routeState();
131
165
  if (!state.route) {
132
166
  return null;
133
167
  }
134
- const elements = [];
135
- collectElements(resolved(), elements);
136
- const {
137
- rendered
138
- } = buildRenderList(elements, state.route.name, props.nodeName);
139
- if (rendered.length > 0) {
140
- return rendered;
141
- }
142
- return null;
168
+ const rendered = buildRenderList(elements(), state.route.name, props.nodeName);
169
+ return rendered.length > 0 ? rendered : null;
143
170
  });
144
171
  }
145
172
  RouteViewRoot.displayName = "RouteView";
@@ -165,14 +192,36 @@ const INTERNAL_ROUTE_PREFIX = "@@";
165
192
  const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0";
166
193
  function createRouteAnnouncer(router, options) {
167
194
  const prefix = "Navigated to ";
195
+ const getCustomText = options?.getAnnouncementText;
168
196
  let isInitialNavigation = true;
169
197
  let isReady = false;
170
198
  let isDestroyed = false;
171
199
  let lastAnnouncedText = "";
200
+ let pendingText = null;
172
201
  let clearTimeoutId;
173
202
  const announcer = getOrCreateAnnouncer();
203
+ const doAnnounce = (text, h1) => {
204
+ lastAnnouncedText = text;
205
+ clearTimeout(clearTimeoutId);
206
+ announcer.textContent = text;
207
+ clearTimeoutId = setTimeout(() => {
208
+ announcer.textContent = "";
209
+ lastAnnouncedText = "";
210
+ }, CLEAR_DELAY);
211
+ manageFocus(h1);
212
+ };
213
+
214
+ // Safari-ready delay: announcing before VoiceOver wires up the aria-live region
215
+ // causes the first announcement to be silently dropped. Wait SAFARI_READY_DELAY ms
216
+ // before marking the announcer "ready" — any navigation during that window is
217
+ // buffered in pendingText and flushed once the delay expires.
174
218
  const safariTimeoutId = setTimeout(() => {
175
219
  isReady = true;
220
+ if (pendingText !== null && !isDestroyed) {
221
+ const text = pendingText;
222
+ pendingText = null;
223
+ doAnnounce(text, document.querySelector("h1"));
224
+ }
176
225
  }, SAFARI_READY_DELAY);
177
226
  const unsubscribe = router.subscribe(({
178
227
  route
@@ -181,22 +230,28 @@ function createRouteAnnouncer(router, options) {
181
230
  isInitialNavigation = false;
182
231
  return;
183
232
  }
233
+
234
+ // Double rAF: waits for two paint frames so the incoming route's DOM
235
+ // (including the new <h1>) is fully rendered before resolveText reads it.
236
+ // Single rAF fires before the new route's template has been attached,
237
+ // which would cause resolveText to pick up the OLD h1 or fall back to
238
+ // document.title / route.name prematurely.
184
239
  requestAnimationFrame(() => {
185
240
  requestAnimationFrame(() => {
186
241
  if (isDestroyed) {
187
242
  return;
188
243
  }
189
- const text = resolveText(route, prefix);
190
- if (text && text !== lastAnnouncedText && isReady) {
191
- lastAnnouncedText = text;
192
- clearTimeout(clearTimeoutId);
193
- announcer.textContent = text;
194
- clearTimeoutId = setTimeout(() => {
195
- announcer.textContent = "";
196
- lastAnnouncedText = "";
197
- }, CLEAR_DELAY);
198
- manageFocus();
244
+ const h1 = document.querySelector("h1");
245
+ const text = resolveText(route, prefix, getCustomText, h1);
246
+ if (!text || text === lastAnnouncedText) {
247
+ return;
248
+ }
249
+ if (!isReady) {
250
+ // Defer announcement until Safari-ready window elapses (see safariTimeoutId).
251
+ pendingText = text;
252
+ return;
199
253
  }
254
+ doAnnounce(text, h1);
200
255
  });
201
256
  });
202
257
  });
@@ -226,15 +281,13 @@ function getOrCreateAnnouncer() {
226
281
  function removeAnnouncer() {
227
282
  document.querySelector(`[${ANNOUNCER_ATTR}]`)?.remove();
228
283
  }
229
- function resolveText(route, prefix, getCustomText) {
230
- const h1 = document.querySelector("h1");
284
+ function resolveText(route, prefix, getCustomText, h1) {
231
285
  const h1Text = h1?.textContent.trim() ?? "";
232
286
  const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX) ? "" : route.name;
233
287
  const rawText = h1Text || document.title || routeName || globalThis.location.pathname;
234
288
  return `${prefix}${rawText}`;
235
289
  }
236
- function manageFocus() {
237
- const h1 = document.querySelector("h1");
290
+ function manageFocus(h1) {
238
291
  if (!h1) {
239
292
  return;
240
293
  }
@@ -253,7 +306,10 @@ function buildHref(router, routeName, routeParams) {
253
306
  try {
254
307
  const buildUrl = router.buildUrl;
255
308
  if (buildUrl) {
256
- return buildUrl(routeName, routeParams);
309
+ const url = buildUrl(routeName, routeParams);
310
+ if (url !== undefined) {
311
+ return url;
312
+ }
257
313
  }
258
314
  return router.buildPath(routeName, routeParams);
259
315
  } catch {
@@ -261,25 +317,67 @@ function buildHref(router, routeName, routeParams) {
261
317
  return undefined;
262
318
  }
263
319
  }
320
+ function parseTokens(value) {
321
+ return value ? value.match(/\S+/g) ?? [] : [];
322
+ }
264
323
  function buildActiveClassName(isActive, activeClassName, baseClassName) {
265
324
  if (isActive && activeClassName) {
266
- return baseClassName ? `${baseClassName} ${activeClassName}`.trim() : activeClassName;
325
+ const activeTokens = parseTokens(activeClassName);
326
+ if (activeTokens.length === 0) {
327
+ return baseClassName ?? undefined;
328
+ }
329
+ if (!baseClassName) {
330
+ return activeTokens.join(" ");
331
+ }
332
+ const baseTokens = parseTokens(baseClassName);
333
+ const seen = new Set(baseTokens);
334
+ for (const token of activeTokens) {
335
+ if (!seen.has(token)) {
336
+ seen.add(token);
337
+ baseTokens.push(token);
338
+ }
339
+ }
340
+ return baseTokens.join(" ");
267
341
  }
268
342
  return baseClassName ?? undefined;
269
343
  }
270
344
  function applyLinkA11y(element) {
345
+ if (!element) {
346
+ return;
347
+ }
271
348
  if (element instanceof HTMLAnchorElement || element instanceof HTMLButtonElement) {
272
349
  return;
273
350
  }
274
- if (!element.getAttribute("role")) {
351
+ if (!element.hasAttribute("role")) {
275
352
  element.setAttribute("role", "link");
276
353
  }
277
- if (!element.getAttribute("tabindex")) {
354
+ if (!element.hasAttribute("tabindex")) {
278
355
  element.setAttribute("tabindex", "0");
279
356
  }
280
357
  }
281
358
 
282
359
  var _tmpl$ = /*#__PURE__*/web.template(`<a>`);
360
+ // Slow-path source cache: shared per-router, keyed by routeName + params + flags.
361
+ // Captured slow-path values are stable per Link (props captured at init), so the
362
+ // cache key is guaranteed stable for the lifetime of any consumer.
363
+ const activeSourceCache = new WeakMap();
364
+ function getOrCreateActiveSource(router, routeName, routeParams, activeStrict, ignoreQueryParams) {
365
+ let perRouter = activeSourceCache.get(router);
366
+ if (!perRouter) {
367
+ perRouter = new Map();
368
+ activeSourceCache.set(router, perRouter);
369
+ }
370
+ const key = `${routeName}|${JSON.stringify(routeParams)}|${activeStrict}|${ignoreQueryParams}`;
371
+ let source = perRouter.get(key);
372
+ if (!source) {
373
+ source = sources.createActiveRouteSource(router, routeName, routeParams, {
374
+ strict: activeStrict,
375
+ ignoreQueryParams
376
+ });
377
+ perRouter.set(key, source);
378
+ }
379
+ return source;
380
+ }
283
381
  function Link(props) {
284
382
  const merged = solidJs.mergeProps({
285
383
  routeParams: EMPTY_PARAMS,
@@ -289,13 +387,13 @@ function Link(props) {
289
387
  ignoreQueryParams: true
290
388
  }, props);
291
389
  const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
292
- const router = useRouter();
293
390
  const ctx = solidJs.useContext(RouterContext);
294
- const useFastPath = ctx?.routeSelector && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
295
- const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, {
296
- strict: local.activeStrict,
297
- ignoreQueryParams: local.ignoreQueryParams
298
- }));
391
+ if (!ctx) {
392
+ throw new Error("Link must be used within a RouterProvider");
393
+ }
394
+ const router = ctx.router;
395
+ const useFastPath = !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
396
+ const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(getOrCreateActiveSource(router, local.routeName, local.routeParams, local.activeStrict, local.ignoreQueryParams));
299
397
  const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams));
300
398
  const handleClick = evt => {
301
399
  if (local.onClick) {
@@ -327,13 +425,13 @@ function Link(props) {
327
425
  })();
328
426
  }
329
427
 
330
- const cache = new WeakMap();
428
+ const cache$1 = new WeakMap();
331
429
  function useRouterError() {
332
430
  const router = useRouter();
333
- let source = cache.get(router);
431
+ let source = cache$1.get(router);
334
432
  if (!source) {
335
433
  source = sources.createErrorSource(router);
336
- cache.set(router, source);
434
+ cache$1.set(router, source);
337
435
  }
338
436
  return createSignalFromSource(source);
339
437
  }
@@ -352,9 +450,11 @@ function RouterErrorBoundary(props) {
352
450
  return snap.version > dismissedVersion() ? snap.error : null;
353
451
  });
354
452
  const resetError = () => setDismissedVersion(snapshot().version);
355
- return [web.memo(() => props.children), web.memo(() => {
356
- const error = visibleError();
357
- return error ? props.fallback(error, resetError) : null;
453
+ return [web.memo(() => props.children), web.createComponent(solidJs.Show, {
454
+ get when() {
455
+ return visibleError();
456
+ },
457
+ children: error => props.fallback(error(), resetError)
358
458
  })];
359
459
  }
360
460
 
@@ -424,7 +524,9 @@ const useRoute = () => {
424
524
  };
425
525
 
426
526
  function createStoreFromSource(source) {
427
- const [state, setState] = store.createStore(structuredClone(source.getSnapshot()));
527
+ const [state, setState] = store.createStore({
528
+ ...source.getSnapshot()
529
+ });
428
530
  const unsubscribe = source.subscribe(() => {
429
531
  setState(store.reconcile(source.getSnapshot()));
430
532
  });
@@ -433,23 +535,24 @@ function createStoreFromSource(source) {
433
535
  }
434
536
 
435
537
  function useRouteStore() {
436
- const ctx = solidJs.useContext(RouteContext);
437
- if (!ctx) {
438
- throw new Error("useRouteStore must be used within a RouterProvider");
439
- }
440
538
  const router = useRouter();
441
539
  return createStoreFromSource(sources.createRouteSource(router));
442
540
  }
443
541
 
444
542
  function useRouteNodeStore(nodeName) {
445
543
  const router = useRouter();
446
- return createStoreFromSource(sources.createRouteNodeSource(router, nodeName));
544
+ return createStoreFromSource(getOrCreateNodeSource(router, nodeName));
447
545
  }
448
546
 
547
+ const cache = new WeakMap();
449
548
  function useRouterTransition() {
450
549
  const router = useRouter();
451
- const store = sources.createTransitionSource(router);
452
- return createSignalFromSource(store);
550
+ let source = cache.get(router);
551
+ if (!source) {
552
+ source = sources.createTransitionSource(router);
553
+ cache.set(router, source);
554
+ }
555
+ return createSignalFromSource(source);
453
556
  }
454
557
 
455
558
  function isRouteActive(linkRouteName, currentRouteName) {