create-aziz-app 3.1.2 → 3.1.3

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 (33) hide show
  1. package/README.md +658 -207
  2. package/bin/create-aziz-app.js +302 -302
  3. package/package.json +1 -1
  4. package/template/README.md +402 -402
  5. package/template/_framework/api.js +219 -219
  6. package/template/_framework/boot.js +33 -33
  7. package/template/_framework/hooks.js +367 -291
  8. package/template/_framework/next-response.js +128 -128
  9. package/template/_framework/route-match.js +145 -145
  10. package/template/_framework/router.js +465 -259
  11. package/template/_framework/runtime.js +264 -264
  12. package/template/app/layout.js +18 -18
  13. package/template/app/page.js +33 -33
  14. package/template/docs/01-getting-started.md +151 -151
  15. package/template/docs/02-project-structure.md +109 -109
  16. package/template/docs/03-pages-and-layouts.md +140 -140
  17. package/template/docs/04-routing.md +120 -120
  18. package/template/docs/05-hooks.md +169 -169
  19. package/template/docs/06-api-routes.md +210 -210
  20. package/template/docs/07-api-client.md +151 -151
  21. package/template/docs/08-styling.md +94 -94
  22. package/template/docs/09-imports-and-modules.md +97 -97
  23. package/template/docs/10-database-and-models.md +154 -154
  24. package/template/docs/11-authentication.md +171 -171
  25. package/template/docs/12-production-and-deployment.md +121 -121
  26. package/template/docs/13-internals.md +179 -179
  27. package/template/docs/README.md +67 -67
  28. package/template/index.html +20 -20
  29. package/template/package.json +16 -16
  30. package/template/scripts/dev-server.js +493 -381
  31. package/template/scripts/lib/api-router.js +525 -525
  32. package/template/scripts/lib/js-transform.js +213 -195
  33. package/template/scripts/lib/module-resolver.js +132 -132
@@ -1,261 +1,467 @@
1
- // ============================================================
2
- // Aziz.js Router
3
- // File-based routing like the Next.js App Router.
4
- //
5
- // app/
6
- // layout.js → root layout (wraps everything)
7
- // page.js → /
8
- // not-found.js → custom 404
9
- // about/
10
- // page.js → /about
11
- // layout.js → layout for /about and children
12
- // blog/
13
- // [slug]/
14
- // page.js → /blog/:slug
15
- // ============================================================
16
-
1
+ // ============================================================
2
+ // Aziz.js Router
3
+ // File-based routing like the Next.js App Router.
4
+ //
5
+ // app/
6
+ // layout.js → root layout (wraps everything)
7
+ // page.js → /
8
+ // not-found.js → custom 404
9
+ // about/
10
+ // page.js → /about
11
+ // layout.js → layout for /about and children
12
+ // blog/
13
+ // [slug]/
14
+ // page.js → /blog/:slug
15
+ // ============================================================
16
+
17
17
  import { h, mountPage, disposeFibers, beginRender, materialize, appendNodes } from '/_framework/runtime.js';
18
- import { normalizePath, resolveRoute, collectLayouts } from '/_framework/route-match.js';
19
-
20
- // Registered routes / layouts (populated by the dev server manifest)
21
- const routes = {};
22
- const layouts = [];
23
- let notFoundModule = null;
24
- let dev = true;
25
-
26
- // ─── Navigation ──────────────────────────────────────────────
27
-
28
- export function navigate(path) {
29
- history.pushState(null, '', path);
30
- router.render();
31
- }
32
-
33
- // <Link href="/about">About</Link>
34
- export function Link({ href, children, className, style, onClick, ...rest }) {
35
- return h('a', {
36
- ...rest,
37
- href,
38
- class: className,
39
- style,
40
- onClick: (e) => {
41
- if (typeof onClick === 'function') onClick(e);
42
- if (e.defaultPrevented) return;
43
- // Respect modifier clicks, middle-click, external links
44
- if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
45
- if (!href || /^([a-z]+:)?\/\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
46
- e.preventDefault();
47
- navigate(href);
48
- },
49
- }, ...(Array.isArray(children) ? children : [children]));
50
- }
51
-
52
- // ─── Router hooks ────────────────────────────────────────────
53
-
54
- export function useRouter() {
55
- return {
56
- pathname: normalizePath(window.location.pathname),
57
- push: navigate,
58
- replace: (path) => { history.replaceState(null, '', path); router.render(); },
59
- back: () => history.back(),
60
- forward: () => history.forward(),
61
- refresh: () => router.render(),
62
- params: router.currentParams || {},
63
- };
64
- }
65
-
66
- export function useParams() {
67
- return router.currentParams || {};
68
- }
69
-
70
- export function useSearchParams() {
71
- const params = new URLSearchParams(window.location.search);
72
- return {
73
- get: (key) => params.get(key),
74
- getAll: (key) => params.getAll(key),
75
- has: (key) => params.has(key),
76
- entries: () => params.entries(),
77
- toString: () => params.toString(),
78
- };
79
- }
80
-
81
- // ─── Default 404 ─────────────────────────────────────────────
82
-
83
- function DefaultNotFound() {
84
- return h('div', { style: { padding: '60px 24px', textAlign: 'center' } },
85
- h('h1', { style: { fontSize: '72px', color: '#61dafb', margin: '0 0 8px' } }, '404'),
86
- h('p', { style: { color: '#666', marginBottom: '24px' } }, 'This page could not be found.'),
87
- h('button', {
88
- class: 'btn btn-primary',
89
- style: { padding: '10px 24px', cursor: 'pointer' },
90
- onClick: () => navigate('/'),
91
- }, 'Go Home')
92
- );
93
- }
94
-
95
- // ─── Focus preservation ──────────────────────────────────────
96
-
97
- function captureFocus() {
98
- const el = document.activeElement;
99
- if (!el || el === document.body) return null;
100
- if (!/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return null;
101
- return {
102
- id: el.id || null,
103
- name: el.getAttribute?.('name') || null,
104
- placeholder: el.getAttribute?.('placeholder') || null,
105
- tag: el.tagName,
106
- start: el.selectionStart ?? null,
107
- end: el.selectionEnd ?? null,
108
- };
109
- }
110
-
111
- function restoreFocus(root, saved) {
112
- if (!saved) return;
113
- let el = null;
114
- if (saved.id) el = root.querySelector(`#${CSS.escape(saved.id)}`);
115
- if (!el && saved.name) el = root.querySelector(`[name="${CSS.escape(saved.name)}"]`);
116
- if (!el && saved.placeholder) {
117
- el = root.querySelector(`${saved.tag.toLowerCase()}[placeholder="${CSS.escape(saved.placeholder)}"]`);
118
- }
119
- if (el && typeof el.focus === 'function') {
120
- el.focus({ preventScroll: true });
121
- if (saved.start !== null && typeof el.setSelectionRange === 'function') {
122
- try { el.setSelectionRange(saved.start, saved.end); } catch { /* non-text input */ }
123
- }
124
- }
125
- }
126
-
127
- // ─── Metadata ────────────────────────────────────────────────
128
-
129
- function metaTitle(value) {
130
- if (typeof value === 'string') return value;
131
- if (value && typeof value === 'object') return value.absolute || value.default || '';
132
- return '';
133
- }
134
-
135
- function applyMetadata(layoutModules, pageModule) {
136
- // Outer → inner; page metadata wins last.
137
- const metas = [...layoutModules.map(m => m && m.metadata), pageModule && pageModule.metadata]
138
- .filter(Boolean);
139
- if (metas.length === 0) return;
140
-
141
- const merged = Object.assign({}, ...metas);
142
-
143
- const title = metaTitle(merged.title);
144
- if (title) document.title = title;
145
-
146
- if (merged.description) {
147
- let tag = document.querySelector('meta[name="description"]');
148
- if (!tag) {
149
- tag = document.createElement('meta');
150
- tag.setAttribute('name', 'description');
151
- document.head.appendChild(tag);
152
- }
153
- tag.setAttribute('content', merged.description);
154
- }
155
- }
156
-
157
- // ─── Router object ───────────────────────────────────────────
158
-
159
- export const router = {
160
- layouts,
161
- currentParams: {},
162
- _lastPathname: null,
163
-
164
- register(pathname, module) {
165
- routes[pathname] = module;
166
- },
167
-
168
- registerLayout(pathname, module) {
169
- layouts.push({ path: pathname, module });
170
- },
171
-
172
- registerNotFound(module) {
173
- notFoundModule = module;
174
- },
175
-
176
- async init() {
177
- window.navigate = navigate;
178
- window.useRouter = useRouter;
179
- window.useParams = useParams;
180
- window.useSearchParams = useSearchParams;
181
- window.Link = Link;
182
- window.__rerenderPage = () => this.render();
183
-
184
- await this.loadRoutes();
185
-
186
- window.addEventListener('popstate', () => this.render());
187
-
188
- await this.render();
189
- },
190
-
191
- async loadRoutes() {
192
- let manifest;
193
- try {
194
- const res = await fetch('/_framework/routes.json');
195
- const data = await res.json();
196
- manifest = Array.isArray(data) ? data : data.routes || [];
197
- dev = Array.isArray(data) ? true : data.dev !== false;
198
- window.__azizDev = dev;
199
- } catch (err) {
200
- console.error('❌ Failed to load route manifest:', err);
201
- return;
202
- }
203
-
204
- for (const entry of manifest) {
205
- const { pathname, filePath, type } = entry;
206
- try {
207
- // Cache-bust in development; allow HTTP caching in production
208
- const url = dev ? filePath + '?t=' + Date.now() : filePath;
209
- const mod = await import(url);
210
- if (type === 'page') this.register(pathname, mod);
211
- else if (type === 'layout') this.registerLayout(pathname, mod);
212
- else if (type === 'not-found') this.registerNotFound(mod);
213
- } catch (err) {
214
- console.error(`❌ Failed to load ${filePath}:`, err);
215
- }
216
- }
217
- },
218
-
219
- async render() {
220
- const pathname = normalizePath(window.location.pathname);
221
- const root = document.getElementById('root');
222
- if (!root) return;
223
-
224
- const isNewRoute = this._lastPathname !== pathname;
225
- if (isNewRoute) {
226
- // Unmount previous page: run effect cleanups, drop state
227
- disposeFibers();
228
- this._lastPathname = pathname;
229
- }
230
-
18
+ import { useState, useCallback, getPendingLoads, onLoadState } from '/_framework/hooks.js';
19
+ import { normalizePath, resolveRoute, collectLayouts } from '/_framework/route-match.js';
20
+
21
+ // Registered routes / layouts (populated by the dev server manifest)
22
+ const routes = {};
23
+ const layouts = [];
24
+ const loadings = [];
25
+ const errors = [];
26
+ let notFoundModule = null;
27
+ let dev = true;
28
+
29
+ // How long a route transition may stay pending before loading.js appears.
30
+ // Below this the route is "fast enough" and showing a spinner would only flash.
31
+ const LOADING_DELAY = 200;
32
+
33
+ // The nearest loading.js / error.js for a path — same prefix-inheritance
34
+ // rules as layouts, so app/loading.js covers every route beneath it and a
35
+ // deeper app/blog/loading.js overrides it for /blog only.
36
+ function collectNearest(list, pathname) {
37
+ const matches = list.filter(e => matchesSegment(e.path, pathname));
38
+ if (matches.length === 0) return null;
39
+ matches.sort((a, b) => b.path.split('/').filter(Boolean).length - a.path.split('/').filter(Boolean).length);
40
+ return matches[0];
41
+ }
42
+
43
+ function matchesSegment(pattern, pathname) {
44
+ const p = pattern.split('/').filter(Boolean);
45
+ const s = pathname.split('/').filter(Boolean);
46
+ if (p.length > s.length) return false;
47
+ for (let i = 0; i < p.length; i++) if (p[i] !== s[i]) return false;
48
+ return true;
49
+ }
50
+
51
+ // ─── Navigation ──────────────────────────────────────────────
52
+
53
+ export function navigate(path, { replace = false, scroll = true } = {}) {
54
+ if (replace) history.replaceState(null, '', path);
55
+ else history.pushState(null, '', path);
56
+ router.render();
57
+ if (scroll) window.scrollTo(0, 0);
58
+ }
59
+
60
+ export function replace(path) {
61
+ navigate(path, { replace: true });
62
+ }
63
+
64
+ export function back() { history.back(); }
65
+ export function forward() { history.forward(); }
66
+ export function refresh() { router.render(); }
67
+
68
+ // Server-style redirect from inside a component or an effect.
69
+ export function redirect(url, options) {
70
+ navigate(url, { replace: true, ...options });
71
+ }
72
+
73
+ // Thrown by notFound() to render the 404 screen from a page.
74
+ const NOT_FOUND_SIGNAL = 'AZIZ_NOT_FOUND';
75
+
76
+ export function notFound() {
77
+ throw new Error(NOT_FOUND_SIGNAL);
78
+ }
79
+
80
+ // <Link href="/about">About</Link>
81
+ export function Link({ href, children, className, style, onClick, replace = false, scroll = true, prefetch = false, ...rest }) {
82
+ return h('a', {
83
+ ...rest,
84
+ href,
85
+ class: className,
86
+ style,
87
+ onClick: (e) => {
88
+ if (typeof onClick === 'function') onClick(e);
89
+ if (e.defaultPrevented) return;
90
+ // Respect modifier clicks, middle-click, external links
91
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
92
+ if (!href || /^([a-z]+:)?\/\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
93
+ e.preventDefault();
94
+ navigate(href, { replace, scroll });
95
+ },
96
+ }, ...(Array.isArray(children) ? children : [children]));
97
+ }
98
+
99
+ // ─── Router hooks ────────────────────────────────────────────
100
+
101
+ export function useRouter() {
102
+ return {
103
+ pathname: normalizePath(window.location.pathname),
104
+ push: (path, options) => navigate(path, options),
105
+ replace: (path, options) => navigate(path, { ...options, replace: true }),
106
+ back,
107
+ forward,
108
+ refresh,
109
+ // Aziz.js has no route prefetching (see README) — kept for API parity.
110
+ prefetch: () => Promise.resolve(),
111
+ params: router.currentParams || {},
112
+ };
113
+ }
114
+
115
+ export function useParams() {
116
+ return router.currentParams || {};
117
+ }
118
+
119
+ // Next.js parity: read the current path without reaching for window.location.
120
+ export function usePathname() {
121
+ return normalizePath(window.location.pathname);
122
+ }
123
+
124
+ // Next.js parity: transition state. Client-side routing in Aziz.js is
125
+ // synchronous — there is no server round-trip to await — so isPending is
126
+ // always false between events.
127
+ export function useNavigation() {
128
+ return {
129
+ isPending: false,
130
+ state: 'idle',
131
+ ...useRouter(),
132
+ };
133
+ }
134
+
135
+ // Next.js parity: the real App Router transition hook. Wraps a callback so
136
+ // async work reports isPending while it runs; synchronous callbacks resolve
137
+ // immediately because there is nothing to suspend on.
138
+ export function useTransition() {
139
+ const [isPending, setIsPending] = useState(false);
140
+
141
+ const startTransition = useCallback((callback) => {
142
+ let result;
143
+ try {
144
+ result = callback();
145
+ } catch (err) {
146
+ setIsPending(false);
147
+ throw err;
148
+ }
149
+ if (result && typeof result.then === 'function') {
150
+ setIsPending(true);
151
+ return result.finally(() => setIsPending(false));
152
+ }
153
+ return result;
154
+ }, []);
155
+
156
+ return [isPending, startTransition];
157
+ }
158
+
159
+ export function useSearchParams() {
160
+ const params = new URLSearchParams(window.location.search);
161
+ return {
162
+ get: (key) => params.get(key),
163
+ getAll: (key) => params.getAll(key),
164
+ has: (key) => params.has(key),
165
+ entries: () => params.entries(),
166
+ keys: () => params.keys(),
167
+ values: () => params.values(),
168
+ forEach: (fn) => params.forEach(fn),
169
+ toString: () => params.toString(),
170
+ };
171
+ }
172
+
173
+ // ─── Default 404 ─────────────────────────────────────────────
174
+
175
+ function DefaultNotFound() {
176
+ return h('div', { style: { padding: '60px 24px', textAlign: 'center' } },
177
+ h('h1', { style: { fontSize: '72px', color: '#61dafb', margin: '0 0 8px' } }, '404'),
178
+ h('p', { style: { color: '#666', marginBottom: '24px' } }, 'This page could not be found.'),
179
+ h('button', {
180
+ class: 'btn btn-primary',
181
+ style: { padding: '10px 24px', cursor: 'pointer' },
182
+ onClick: () => navigate('/'),
183
+ }, 'Go Home')
184
+ );
185
+ }
186
+
187
+ // ─── Focus preservation ──────────────────────────────────────
188
+
189
+ function captureFocus() {
190
+ const el = document.activeElement;
191
+ if (!el || el === document.body) return null;
192
+ if (!/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return null;
193
+ return {
194
+ id: el.id || null,
195
+ name: el.getAttribute?.('name') || null,
196
+ placeholder: el.getAttribute?.('placeholder') || null,
197
+ tag: el.tagName,
198
+ start: el.selectionStart ?? null,
199
+ end: el.selectionEnd ?? null,
200
+ };
201
+ }
202
+
203
+ function restoreFocus(root, saved) {
204
+ if (!saved) return;
205
+ let el = null;
206
+ if (saved.id) el = root.querySelector(`#${CSS.escape(saved.id)}`);
207
+ if (!el && saved.name) el = root.querySelector(`[name="${CSS.escape(saved.name)}"]`);
208
+ if (!el && saved.placeholder) {
209
+ el = root.querySelector(`${saved.tag.toLowerCase()}[placeholder="${CSS.escape(saved.placeholder)}"]`);
210
+ }
211
+ if (el && typeof el.focus === 'function') {
212
+ el.focus({ preventScroll: true });
213
+ if (saved.start !== null && typeof el.setSelectionRange === 'function') {
214
+ try { el.setSelectionRange(saved.start, saved.end); } catch { /* non-text input */ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // ─── Metadata ────────────────────────────────────────────────
220
+
221
+ function metaTitle(value) {
222
+ if (typeof value === 'string') return value;
223
+ if (value && typeof value === 'object') return value.absolute || value.default || '';
224
+ return '';
225
+ }
226
+
227
+ function applyMetadata(layoutModules, pageModule) {
228
+ // Outer → inner; page metadata wins last.
229
+ const metas = [...layoutModules.map(m => m && m.metadata), pageModule && pageModule.metadata]
230
+ .filter(Boolean);
231
+ if (metas.length === 0) return;
232
+
233
+ const merged = Object.assign({}, ...metas);
234
+
235
+ const title = metaTitle(merged.title);
236
+ if (title) document.title = title;
237
+
238
+ if (merged.description) {
239
+ let tag = document.querySelector('meta[name="description"]');
240
+ if (!tag) {
241
+ tag = document.createElement('meta');
242
+ tag.setAttribute('name', 'description');
243
+ document.head.appendChild(tag);
244
+ }
245
+ tag.setAttribute('content', merged.description);
246
+ }
247
+ }
248
+
249
+ // ─── Router object ───────────────────────────────────────────
250
+
251
+ export const router = {
252
+ layouts,
253
+ currentParams: {},
254
+ _lastPathname: null,
255
+ // Transition bookkeeping for loading.js. _epoch invalidates stale timers
256
+ // so a fast navigation can never be replaced by a slow one's spinner.
257
+ _epoch: 0,
258
+ _showingLoading: false,
259
+ _pendingAtStart: 0,
260
+
261
+ register(pathname, module) {
262
+ routes[pathname] = module;
263
+ },
264
+
265
+ registerLayout(pathname, module) {
266
+ layouts.push({ path: pathname, module });
267
+ },
268
+
269
+ registerLoading(pathname, module) {
270
+ loadings.push({ path: pathname, module });
271
+ },
272
+
273
+ registerError(pathname, module) {
274
+ errors.push({ path: pathname, module });
275
+ },
276
+
277
+ registerNotFound(module) {
278
+ notFoundModule = module;
279
+ },
280
+
281
+ async init() {
282
+ // Navigation API — globals so pages need no imports.
283
+ window.navigate = navigate;
284
+ window.useRouter = useRouter;
285
+ window.useParams = useParams;
286
+ window.useSearchParams = useSearchParams;
287
+ window.usePathname = usePathname;
288
+ window.useNavigation = useNavigation;
289
+ window.useTransition = useTransition;
290
+ window.Link = Link;
291
+ window.redirect = redirect;
292
+ window.notFound = notFound;
293
+ window.back = back;
294
+ window.forward = forward;
295
+ window.refresh = refresh;
296
+ window.__rerenderPage = () => this.render();
297
+
298
+ await this.loadRoutes();
299
+
300
+ window.addEventListener('popstate', () => this.render());
301
+
302
+ await this.render();
303
+ },
304
+
305
+ async loadRoutes() {
306
+ let manifest;
307
+ try {
308
+ const res = await fetch('/_framework/routes.json');
309
+ const data = await res.json();
310
+ manifest = Array.isArray(data) ? data : data.routes || [];
311
+ dev = Array.isArray(data) ? true : data.dev !== false;
312
+ window.__azizDev = dev;
313
+ } catch (err) {
314
+ console.error('❌ Failed to load route manifest:', err);
315
+ return;
316
+ }
317
+
318
+ for (const entry of manifest) {
319
+ const { pathname, filePath, type } = entry;
320
+ try {
321
+ // Cache-bust in development; allow HTTP caching in production
322
+ const url = dev ? filePath + '?t=' + Date.now() : filePath;
323
+ const mod = await import(url);
324
+ if (type === 'page') this.register(pathname, mod);
325
+ else if (type === 'layout') this.registerLayout(pathname, mod);
326
+ else if (type === 'loading') this.registerLoading(pathname, mod);
327
+ else if (type === 'error') this.registerError(pathname, mod);
328
+ else if (type === 'not-found') this.registerNotFound(mod);
329
+ } catch (err) {
330
+ console.error(`❌ Failed to load ${filePath}:`, err);
331
+ }
332
+ }
333
+ },
334
+
335
+ // ── Route transition (drives loading.js) ─────────────────
336
+ //
337
+ // Every page module is imported up front at boot, so a navigation has no
338
+ // module-fetch window — data fetching is the only genuinely async part.
339
+ // We therefore show the nearest loading.js only if the route still has
340
+ // requests in flight after LOADING_DELAY, which avoids flashing a spinner
341
+ // on fast routes. Called *after* the page has mounted, because the page's
342
+ // effects are what start those requests.
343
+
344
+ _beginTransition(pathname, loadingComponent, pendingAtStart) {
345
+ const epoch = ++this._epoch;
346
+ this._finishTransition(this._epoch - 1);
347
+ this._showingLoading = false;
348
+
349
+ this._pendingAtStart = pendingAtStart;
350
+ if (!loadingComponent) return;
351
+
352
+ // NOTE: the page's own requests start in a microtask (useEffect queues
353
+ // with queueMicrotask), so they are NOT running yet at this point. We
354
+ // therefore always arm the timer and let it inspect reality at
355
+ // LOADING_DELAY rather than deciding synchronously.
356
+ this._transitionStop = onLoadState((pending) => {
357
+ if (epoch !== this._epoch || !this._showingLoading) return;
358
+ if (pending > this._pendingAtStart) return;
359
+ // Everything settled — put the real page back.
360
+ this._showingLoading = false;
361
+ this._finishTransition(epoch);
362
+ this._renderAt(pathname);
363
+ });
364
+
365
+ this._transitionTimer = setTimeout(() => {
366
+ if (epoch !== this._epoch || this._showingLoading) return;
367
+ if (getPendingLoads() > this._pendingAtStart) {
368
+ this._showingLoading = true;
369
+ this._renderAt(pathname, { forceLoading: true });
370
+ }
371
+ }, LOADING_DELAY);
372
+ },
373
+
374
+ _finishTransition(epoch) {
375
+ if (epoch !== this._epoch) return;
376
+ clearTimeout(this._transitionTimer);
377
+ this._transitionTimer = null;
378
+ if (this._transitionStop) { this._transitionStop(); this._transitionStop = null; }
379
+ },
380
+
381
+ async render() {
382
+ return this._renderAt(normalizePath(window.location.pathname));
383
+ },
384
+
385
+ async _renderAt(pathname, { forceLoading = false } = {}) {
386
+ const root = document.getElementById('root');
387
+ if (!root) return;
388
+
389
+ const isNewRoute = this._lastPathname !== pathname;
390
+ if (isNewRoute) {
391
+ // Unmount previous page: run effect cleanups, drop state
392
+ disposeFibers();
393
+ this._lastPathname = pathname;
394
+ }
395
+
231
396
  const scrollY = isNewRoute ? 0 : window.scrollY;
232
-
233
- const match = resolveRoute(routes, pathname);
234
- this.currentParams = match ? match.params : {};
235
-
236
- const layoutChain = collectLayouts(layouts, pathname);
237
- const layoutModules = layoutChain.map(l => l.module);
238
- const layoutComponents = layoutModules.map(m => m.default).filter(Boolean);
239
-
240
- let pageModule = match ? match.value : notFoundModule;
241
- let PageComponent = pageModule && pageModule.default;
242
-
243
- if (!PageComponent) {
244
- PageComponent = DefaultNotFound;
245
- pageModule = null;
246
- }
247
-
248
- applyMetadata(layoutModules, pageModule);
249
-
250
- const saved = captureFocus();
251
-
252
- mountPage(PageComponent, layoutComponents, root, 'page:' + pathname);
253
-
254
- restoreFocus(root, saved);
255
-
256
- if (isNewRoute) window.scrollTo(0, 0);
257
- else if (scrollY) window.scrollTo(0, scrollY);
258
- },
259
- };
260
-
261
- export default router;
397
+ // Baseline before the page's effects kick off their requests.
398
+ const pendingAtStart = getPendingLoads();
399
+
400
+ const match = resolveRoute(routes, pathname);
401
+ this.currentParams = match ? match.params : {};
402
+
403
+ const layoutChain = collectLayouts(layouts, pathname);
404
+ const layoutModules = layoutChain.map(l => l.module);
405
+ const layoutComponents = layoutModules.map(m => m.default).filter(Boolean);
406
+
407
+ let pageModule = match ? match.value : notFoundModule;
408
+ let PageComponent = pageModule && pageModule.default;
409
+
410
+ if (!PageComponent) {
411
+ PageComponent = DefaultNotFound;
412
+ pageModule = null;
413
+ }
414
+
415
+ // Show the route's loading.js instead of the page while data settles.
416
+ if (forceLoading) {
417
+ const loading = collectNearest(loadings, pathname);
418
+ const LoadingComponent = loading && loading.module.default;
419
+ if (LoadingComponent) {
420
+ const savedL = captureFocus();
421
+ mountPage(LoadingComponent, layoutComponents, root, 'loading:' + pathname);
422
+ restoreFocus(root, savedL);
423
+ if (isNewRoute) window.scrollTo(0, 0);
424
+ return;
425
+ }
426
+ }
427
+
428
+ const boundary = collectNearest(errors, pathname);
429
+ const ErrorComponent = boundary && boundary.module.default;
430
+ const saved = captureFocus();
431
+
432
+ try {
433
+ mountPage(PageComponent, layoutComponents, root, 'page:' + pathname);
434
+ if (forceLoading) this._finishTransition(this._epoch);
435
+ } catch (err) {
436
+ // A page called notFound() — swap in the 404 screen.
437
+ if (err && err.message === NOT_FOUND_SIGNAL) {
438
+ disposeFibers();
439
+ const NotFound = (notFoundModule && notFoundModule.default) || DefaultNotFound;
440
+ mountPage(NotFound, layoutComponents, root, 'notfound:' + pathname);
441
+ applyMetadata(layoutModules, notFoundModule);
442
+ } else if (ErrorComponent) {
443
+ // A real render error — hand it to the nearest error boundary
444
+ // instead of white-screening the app. Next.js shape:
445
+ // error.js receives { error, reset }.
446
+ disposeFibers();
447
+ console.error('❌ Error while rendering', pathname, err);
448
+ mountPage(ErrorComponent, layoutComponents, root, 'error:' + pathname, {
449
+ error: err,
450
+ reset: () => { this._epoch++; this._showingLoading = false; this.render(); },
451
+ });
452
+ applyMetadata(layoutModules, null);
453
+ } else {
454
+ throw err;
455
+ }
456
+ }
457
+
458
+ restoreFocus(root, saved);
459
+
460
+ if (isNewRoute) window.scrollTo(0, 0);
461
+ else if (scrollY) window.scrollTo(0, scrollY);
462
+
463
+ this._beginTransition(pathname, collectNearest(loadings, pathname), pendingAtStart);
464
+ },
465
+ };
466
+
467
+ export default router;