@ultimat3/render 1.2.0 → 3.0.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.
package/src/route.ts CHANGED
@@ -3,15 +3,22 @@
3
3
  * offline strategy, hydration timing and metadata, and it hands back a descriptor that is
4
4
  * already normalized: `meta` always awaits, `budget` is always there.
5
5
  *
6
- * `offline`, `hydrate` and `meta` are REQUIRED BY THE TYPE. That is axiom 3 — enforced,
7
- * not documented — expressed in the type system: a route that forgets its offline
8
- * strategy or its `<head>` is a compile error, not a checklist item nobody reads.
6
+ * `offline` and `meta` are REQUIRED BY THE TYPE. That is axiom 3 — enforced, not documented —
7
+ * expressed in the type system: a route that forgets its offline strategy or its `<head>` is a
8
+ * compile error, not a checklist item nobody reads.
9
+ *
10
+ * `hydrate` is not on that list, since 1.2.0: it is the one key the framework can work out from
11
+ * the page's own declarations, and requiring a value it already knows is not enforcement, it is a
12
+ * second place to get one thing wrong.
9
13
  */
10
14
 
11
15
  import type { CacheTag } from '@ultimat3/cache';
12
16
  import { serializeTags } from '@ultimat3/cache';
17
+ import type { Translator } from '@ultimat3/i18n';
13
18
  import type { RouteMeta } from '@ultimat3/seo';
14
- import { RouteMetaMissingError, RouteOfflineMissingError } from './errors';
19
+ import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError } from './errors';
20
+ import type { IslandSpec } from './island';
21
+ import { drainDeclaredIslands } from './island';
15
22
  import { assertModeShape } from './modes';
16
23
 
17
24
  export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
@@ -21,6 +28,15 @@ export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
21
28
  export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const;
22
29
  export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const;
23
30
 
31
+ /**
32
+ * What a page that declares an island hydrates as when it says nothing. The most conservative of
33
+ * the three that ship JavaScript: nothing runs until the visitor acts, and `interaction` is the
34
+ * only one that also replays the event that woke the island, so the first click is answered rather
35
+ * than swallowed. An island wanting `visible` (an infinite scroll) still says so once — on the
36
+ * route, the same key that overrides this one, never a second declaration on the island itself.
37
+ */
38
+ export const DEFAULT_ISLAND_HYDRATE: HydrateStrategy = 'interaction';
39
+
24
40
  export type RouteParams = Readonly<Record<string, string>>;
25
41
  export type RouteData = Readonly<Record<string, unknown>>;
26
42
 
@@ -50,25 +66,102 @@ export interface RouteGuard {
50
66
  readonly permission: string;
51
67
  }
52
68
 
69
+ /**
70
+ * What a loader and a `meta` function are told about the request. `url` is a string because that
71
+ * is what `ld.*` embeds and what `meta` already received before `load` existed.
72
+ *
73
+ * An alias rather than an `interface`, deliberately: absent a `load` this object IS the route's
74
+ * data, and only an alias carries the implicit index signature that makes it a `RouteData`. As an
75
+ * interface the compiler could not see that, so the one place it mattered — `routeDataFor`'s
76
+ * fallback — laundered it through `as unknown as TData` and checked nothing at all.
77
+ */
78
+ export type RouteContext = {
79
+ readonly params: RouteParams;
80
+ readonly url: string;
81
+ };
82
+
83
+ /**
84
+ * The route's server-side data, resolved ONCE per render and handed to both `meta` and the page.
85
+ *
86
+ * This is not a ninth concern bolted onto the contract — it completes the one `meta` already
87
+ * declares. `RouteMetaFn<TData>` has always taken a `TData`, and until this existed nothing could
88
+ * ever supply one richer than `{ url, params }`: the page component was passed the same two
89
+ * fields, so a page that fetched its own data could not share it with `meta`. The consequence was
90
+ * silent and severe — a `site/` route's `<title>` and `description` could never reflect its
91
+ * content, on the surface whose entire purpose is SEO, and a page written against `props.data`
92
+ * rendered an empty list with a 200 rather than an error.
93
+ *
94
+ * Sync or async, whichever the page's data needs.
95
+ */
96
+ export type RouteLoadFn<TData = RouteData> = (ctx: RouteContext) => TData | Promise<TData>;
97
+
98
+ /** What the descriptor hands back. One shape, so no consumer branches on a thenable. */
99
+ export type RouteLoadAsyncFn<TData = RouteData> = (ctx: RouteContext) => Promise<TData>;
100
+
101
+ /**
102
+ * What `meta` is given: the loaded data, the request, and the translator.
103
+ *
104
+ * A context rather than the bare data, because a `<title>` needs all three — the data for the
105
+ * content, `url` for the canonical, and `t` because no user-facing string may be hardcoded. It is
106
+ * a strict SUPERSET of what `meta` received before `load` existed (`{ params, url }`), and both
107
+ * keys keep their names, so every route that reads `data.url` or `data.params` keeps working.
108
+ */
109
+ export interface RouteMetaContext<TData = RouteData> {
110
+ readonly data: TData;
111
+ readonly params: RouteParams;
112
+ readonly url: string;
113
+ /** The request's own translator. Never a hardcoded string in a `<title>`. */
114
+ readonly t: Translator;
115
+ }
116
+
53
117
  /** What an author writes. Sync or async, whichever the page's data needs. */
54
- export type RouteMetaFn<TData = RouteData> = (data: TData) => RouteMeta | Promise<RouteMeta>;
118
+ export type RouteMetaFn<TData = RouteData> = (
119
+ ctx: RouteMetaContext<TData>,
120
+ ) => RouteMeta | Promise<RouteMeta>;
55
121
 
56
122
  /** What the descriptor hands back. One shape, so no consumer branches on a thenable. */
57
- export type RouteMetaAsyncFn<TData = RouteData> = (data: TData) => Promise<RouteMeta>;
123
+ export type RouteMetaAsyncFn<TData = RouteData> = (
124
+ ctx: RouteMetaContext<TData>,
125
+ ) => Promise<RouteMeta>;
58
126
 
59
127
  /** Returns the params to build at deploy time. Bare strings fill a single dynamic param. */
60
128
  export type PrerenderFn = () =>
61
129
  | readonly (string | RouteParams)[]
62
130
  | Promise<readonly (string | RouteParams)[]>;
63
131
 
64
- /** The input shape of `defineRoute` — exactly the contract's eight keys, nothing else. */
132
+ /**
133
+ * The rule that makes the no-`load` fallback true: a route that loads nothing renders
134
+ * `{ params, url }`, so its `meta` may only read what the context itself supplies. Any richer
135
+ * `TData` has to come from a loader, and this intersection is what says so — `nothing` when the
136
+ * context already satisfies `TData`, a required `load` when it does not.
137
+ *
138
+ * Enforced, not documented (axiom 3): a `meta` reading `data.post` off a route that declares no
139
+ * `load` is a compile error here, rather than `undefined` in a `<title>` on the surface whose
140
+ * entire purpose is SEO.
141
+ */
142
+ export type LoadRequirement<TData> = RouteContext extends TData
143
+ ? unknown
144
+ : { readonly load: RouteLoadFn<TData> };
145
+
146
+ /** The input shape of `defineRoute` — exactly the contract's nine keys, nothing else. */
65
147
  export interface RouteDefinition<TData = RouteData> {
66
148
  readonly render: RenderMode;
67
149
  readonly revalidate?: RevalidateConfig;
68
150
  readonly prerender?: PrerenderFn;
69
151
  readonly offline: OfflineStrategy;
70
- readonly hydrate: HydrateStrategy;
152
+ /**
153
+ * Optional since 1.2.0, and derived when omitted: a page that declares an island hydrates
154
+ * (`DEFAULT_ISLAND_HYDRATE`), a page that declares none ships nothing (`'never'`). Stating it is
155
+ * still the one override, and still the only way to say `visible` or `idle`.
156
+ *
157
+ * It was required, and the two failures that made it worth deriving were both the framework
158
+ * asking for a value it could already work out: an island on a route still at `'never'` is
159
+ * `X_ISLAND_NOT_HYDRATED`, and a `site/` route off `'never'` with no `budget.js` is refused at
160
+ * registration. Two punishments for one omission the declaration above already answered.
161
+ */
162
+ readonly hydrate?: HydrateStrategy;
71
163
  readonly budget?: RouteBudget;
164
+ readonly load?: RouteLoadFn<TData>;
72
165
  readonly meta: RouteMetaFn<TData>;
73
166
  readonly policy?: RouteGuard;
74
167
  }
@@ -76,15 +169,24 @@ export interface RouteDefinition<TData = RouteData> {
76
169
  /**
77
170
  * The frozen descriptor. `kind` lets the registry reject non-route exports.
78
171
  *
79
- * Two fields are narrower here than in the declaration so every consumer reads one shape:
80
- * `meta` always returns a promise, and `budget` is always an object. Its *fields* stay
81
- * optional — `budget.js === undefined` still means "this route declared no JS budget",
82
- * which is exactly what `modes.ts` fails a hydrating `site/` route on.
172
+ * Three fields are narrower here than in the declaration so every consumer reads one shape:
173
+ * `meta` always returns a promise, `budget` is always an object, and `hydrate` is always one of
174
+ * the four strategies — resolved, so nothing downstream repeats the derivation. `budget`'s
175
+ * *fields* stay optional; `budget.js === undefined` still means "no JS budget declared", which is
176
+ * what `registry.ts` fills in for an island route and `modes.ts` fails a hydrating `site/` route on.
83
177
  */
84
178
  export interface RouteConfig<TData = RouteData> extends RouteDefinition<TData> {
85
179
  readonly kind: 'route';
86
180
  readonly meta: RouteMetaAsyncFn<TData>;
181
+ readonly load?: RouteLoadAsyncFn<TData>;
182
+ readonly hydrate: HydrateStrategy;
87
183
  readonly budget: RouteBudget;
184
+ /**
185
+ * The islands this module declared, in declaration order. Never written by an author — drained
186
+ * from `island()`, and the reason `hydrate` and `budget.js` do not have to be. Also what finally
187
+ * populates `RouteEntry.islands`, which `routeJsBytes` has always read and nothing ever filled.
188
+ */
189
+ readonly islands: readonly IslandSpec[];
88
190
  }
89
191
 
90
192
  /** What every render mode hands back to `@ultimat3/http`'s `html()` / `stream()`. */
@@ -106,9 +208,12 @@ export function tagKeys(tags: readonly CacheTag[] | undefined): readonly string[
106
208
  * Declare a route. Validates the shape (for JS callers who bypass the types) and the
107
209
  * mode-local invariants immediately, so a bad route fails at module evaluation — build
108
210
  * time — rather than on the first request in production.
211
+ *
212
+ * `LoadRequirement` is the half the compiler owns: a `meta` reading data the context cannot
213
+ * supply forces a `load`, which is what lets `routeDataFor` hand the context back as the data.
109
214
  */
110
215
  export function defineRoute<TData = RouteData>(
111
- definition: RouteDefinition<TData>,
216
+ definition: RouteDefinition<TData> & LoadRequirement<TData>,
112
217
  ): RouteConfig<TData> {
113
218
  const def = definition as Partial<RouteDefinition<TData>>;
114
219
 
@@ -131,19 +236,36 @@ export function defineRoute<TData = RouteData>(
131
236
  );
132
237
  }
133
238
 
239
+ if (def.load !== undefined && typeof def.load !== 'function') {
240
+ throw new RouteLoadInvalidError(
241
+ `load: ${JSON.stringify(def.load)} is not a function`,
242
+ 'make load a function of ({ params, url }) returning the page data, or remove it',
243
+ );
244
+ }
245
+
134
246
  const declaredMeta = def.meta;
247
+ const declaredLoad = def.load;
248
+ // Drained unconditionally, even when `hydrate` is stated: the list must not survive into the
249
+ // next route defined in this process, and `RouteEntry.islands` wants it either way.
250
+ const islands = drainDeclaredIslands();
135
251
  const config: RouteConfig<TData> = {
136
252
  kind: 'route',
137
253
  render: def.render as RenderMode,
138
254
  offline: def.offline,
139
- hydrate: def.hydrate as HydrateStrategy,
255
+ islands,
256
+ // Declared wins, always — including `hydrate: 'never'` on a page that has an island, which is
257
+ // a contradiction an author stated on purpose and `X_ISLAND_NOT_HYDRATED` still refuses.
258
+ hydrate: def.hydrate ?? (islands.length > 0 ? DEFAULT_ISLAND_HYDRATE : 'never'),
140
259
  // Wrapped rather than stored: the declaration may be sync, the descriptor never is.
141
260
  // A meta that throws synchronously becomes a rejection here, so `await config.meta(d)`
142
261
  // is the one way to fail as well as the one way to succeed.
143
- meta: async (data: TData) => declaredMeta(data),
262
+ meta: async (metaCtx: RouteMetaContext<TData>) => declaredMeta(metaCtx),
144
263
  // Always an object. `budget.js` is the only reach a consumer needs, so an undeclared
145
264
  // budget is `{}` instead of a second undefined-check at every call site.
146
265
  budget: def.budget ?? {},
266
+ // Wrapped exactly as `meta` is, and for the same reason: the declaration may be sync, the
267
+ // descriptor never is, so `await config.load(ctx)` is the one way to fail as well as succeed.
268
+ ...(declaredLoad ? { load: async (ctx: RouteContext) => declaredLoad(ctx) } : {}),
147
269
  ...(def.revalidate ? { revalidate: def.revalidate } : {}),
148
270
  ...(def.prerender ? { prerender: def.prerender } : {}),
149
271
  ...(def.policy ? { policy: def.policy } : {}),
package/src/surfaces.ts CHANGED
@@ -61,11 +61,30 @@ export const SURFACE_SPECS: Readonly<Record<Surface, SurfaceSpec>> = Object.free
61
61
 
62
62
  const SURFACE_SEGMENT = /(?:^|\/)(site|app|api|shared)\//;
63
63
 
64
+ /** Where the surface segment is, and what follows it — the two halves of one match. */
65
+ export interface SurfaceLocation {
66
+ readonly surface: Surface;
67
+ /** Everything after `<surface>/`. `apps/myapp/app/dashboard/page.tsx` → `dashboard/page.tsx`. */
68
+ readonly rest: string;
69
+ }
70
+
71
+ /**
72
+ * The ONE reader of the surface segment, because the two readers disagreed: this regex is
73
+ * anchored on a path separator, and the route table's `indexOf('app/')` was not — so
74
+ * `apps/myapp/app/page.tsx` took its surface from here and its URL from the `app/` inside
75
+ * `myapp/`, and served every route in that app one segment too deep (`/app` instead of `/`).
76
+ */
77
+ export function locateSurface(file: string): SurfaceLocation | null {
78
+ const normalized = normalize(file);
79
+ const match = SURFACE_SEGMENT.exec(normalized);
80
+ const found = match?.[1];
81
+ if (found === undefined || match === null) return null;
82
+ return { surface: found as Surface, rest: normalized.slice(match.index + match[0].length) };
83
+ }
84
+
64
85
  /** `apps/web/site/pricing/page.tsx` → `site`. Returns null for files outside a surface. */
65
86
  export function surfaceOf(file: string): Surface | null {
66
- const match = SURFACE_SEGMENT.exec(normalize(file));
67
- const found = match?.[1];
68
- return found === undefined ? null : (found as Surface);
87
+ return locateSurface(file)?.surface ?? null;
69
88
  }
70
89
 
71
90
  function normalize(file: string): string {
@@ -0,0 +1,101 @@
1
+ // Compile-time pins for the route's data seam and for island JSX. Source, not a `.test.ts`, on
2
+ // purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
3
+ // type-level claim written in one can never fail. This module emits nothing and exports nothing
4
+ // anybody imports — a regression here is a build error, the only enforcement that counts.
5
+ //
6
+ // `.tsx`, not `.ts`, since 1.2.0: half of what is pinned here is only decidable by writing the JSX
7
+ // an author writes. `jsxImportSource` is `solid-js` in `tsconfig.base.json` and in both tracked
8
+ // apps, so the `<ContactSales />` below is checked against the SAME `JSX.Element` a page is —
9
+ // which is the only way a regression to `IslandNode` fails a build rather than a reader's review.
10
+
11
+ import type { IslandComponent } from './island';
12
+ import { island } from './island';
13
+ import type { JsonValue } from './island-props';
14
+ import type { LoadRequirement, RouteContext, RouteData, RouteDefinition } from './route';
15
+
16
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
17
+ type Assert<T extends true> = T;
18
+
19
+ type BlogPost = { readonly post: { readonly title: string } };
20
+
21
+ /**
22
+ * What makes `routeDataFor`'s no-`load` branch sound. `RouteContext` has to BE a `RouteData` —
23
+ * an `interface` is not, because only an alias gets the implicit index signature, and declaring
24
+ * it as one is what turned that branch's `as unknown as TData` into a checked narrowing.
25
+ */
26
+ export type _ContextIsRouteData = Assert<RouteContext extends RouteData ? true : false>;
27
+
28
+ /**
29
+ * A route that loads nothing still declares nothing extra. `LoadRequirement` collapses to
30
+ * `unknown` for the default `TData`, so every route that shipped before `load` existed — and
31
+ * every route that only reads `data.url` / `data.params` — keeps compiling untouched.
32
+ */
33
+ export type _DefaultDataNeedsNoLoad = Assert<
34
+ RouteDefinition extends RouteDefinition & LoadRequirement<RouteData> ? true : false
35
+ >;
36
+
37
+ /**
38
+ * …and a `meta` reading a field the context cannot supply forces one. Pinned as the required
39
+ * `load` key rather than "does not compile", because that is the exact thing the intersection
40
+ * adds: without it, `defineRoute<BlogPost>({ meta: (c) => c.data.post.title })` type-checked and
41
+ * rendered `undefined` in a `<title>`.
42
+ */
43
+ export type _RicherDataRequiresALoad = Assert<
44
+ undefined extends LoadRequirement<BlogPost>['load'] ? false : true
45
+ >;
46
+
47
+ type ContactModal = IslandComponent<readonly ['subject']>;
48
+ type ContactModalProps = Parameters<ContactModal>[0];
49
+
50
+ /**
51
+ * The declared prop is required and typed, so the call site that forgets it does not compile.
52
+ * `props: ['subject']` is the whole declaration — there is no second place to state the shape.
53
+ */
54
+ export type _DeclaredPropIsRequired = Assert<
55
+ ContactModalProps extends { readonly subject: JsonValue } ? true : false
56
+ >;
57
+
58
+ /**
59
+ * An island's props are exactly what it declared. Anything else has no type here, which is what
60
+ * makes `<Modal {…row} />` a compile error before it is the runtime error that names the column.
61
+ */
62
+ export type _UndeclaredPropHasNoType = Assert<
63
+ 'passwordHash' extends keyof ContactModalProps ? false : true
64
+ >;
65
+
66
+ /**
67
+ * The children are the server-rendered shell and never JSON: they stay `unknown`, so nothing in
68
+ * the type system suggests a component or a handler could travel to the browser inside them.
69
+ */
70
+ export type _ChildrenAreNotJson = Assert<
71
+ ContactModalProps['children'] extends JsonValue | undefined ? false : true
72
+ >;
73
+
74
+ /** A value the browser could never receive is not a `JsonValue`, so the prop never type-checks. */
75
+ export type _AHandleIsNotJson = Assert<(() => void) extends JsonValue ? false : true>;
76
+
77
+ const ContactSales: ContactModal = island({
78
+ src: './contact-sales.island.tsx',
79
+ props: ['subject'],
80
+ });
81
+
82
+ /**
83
+ * The pin that a `.ts` file cannot carry: an island used the way every author reaches for it.
84
+ *
85
+ * `island()` returned a plain object node until 1.2.0, and every `<ContactSales />` in an app was
86
+ * TS2786 — "its return type 'IslandNode' is not a valid JSX element" — while `h(ContactSales, …)`
87
+ * compiled, which is why the framework's own island tests never saw it. The configured
88
+ * `JSX.Element` is `solid-js`'s, a type ALIAS and therefore unaugmentable, whose only
89
+ * object-shaped member is `ArrayElement`. `IslandNode` is that array, and this function is what
90
+ * says so in a form `tsc` reads.
91
+ *
92
+ * Not `: JSX.Element` — render must not import `solid-js`. `unknown` is enough: the error lands on
93
+ * the element, never on the return.
94
+ */
95
+ export function _IslandIsAJsxComponent(): unknown {
96
+ return (
97
+ <ContactSales subject="pricing">
98
+ <p>the server-rendered shell</p>
99
+ </ContactSales>
100
+ );
101
+ }