@wular/pnext 0.0.2 → 0.0.4

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 (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -1,98 +1,27 @@
1
1
  import ReactCompat from 'preact/compat';
2
- import { options, type ComponentChildren, type ComponentType, type VNode } from 'preact';
3
- import { wrapComponentForPrimitiveThrows } from '../client/errors/primitive-throw';
4
-
5
- const reactForwardRefSymbol = Symbol.for('react.forward_ref');
6
- const react19RefCompatInstalled = Symbol.for('pnext.react19-ref-compat-installed');
7
- const reactTextSeparatorSymbol = Symbol.for('pnext.react-text-separator');
8
-
9
- type PNextPreactOptions = typeof options & {
10
- [react19RefCompatInstalled]?: true;
11
- };
12
-
13
- type RefCompatibleComponent = ComponentType<Record<string, unknown>> & {
14
- $$typeof?: symbol;
15
- prototype?: { render?: unknown };
16
- };
17
-
18
- // preact's `options.vnode` is process-global and cannot be uninstalled, so its BEHAVIOR follows the
19
- // active extension host instead: one server process can serve a compat app and then a pure-core one, and
20
- // the core app must not inherit React parity it never asked for. Defaults to on - importing this module
21
- // IS compat being wired, and the client never resets.
22
- let reactCompatActive = true;
23
-
24
- /** Enable/disable the react-compat vnode parity pass (see reactCompatActive). */
25
- export function setReactCompatActive(active: boolean): void {
26
- reactCompatActive = active;
27
- }
28
-
29
- const pnextOptions = options as PNextPreactOptions;
30
- if (!pnextOptions[react19RefCompatInstalled]) {
31
- const previousVNode = options.vnode?.bind(options);
32
- options.vnode = vnode => {
33
- previousVNode?.(vnode);
34
- if (!reactCompatActive) return;
35
- applyReact19RefProp(vnode);
36
- applyAsyncClientComponent(vnode);
37
- applyPrimitiveThrowSafety(vnode);
38
- applyThenableChildren(vnode);
39
- applyReactTextSeparators(vnode);
40
- };
41
- pnextOptions[react19RefCompatInstalled] = true;
42
-
43
- // Effect scheduling parity with React: preact/hooks flushes passive effects
44
- // via requestAnimationFrame + setTimeout(35) (afterNextFrame), both governed
45
- // by the page clock. React flushes them through its scheduler's
46
- // MessageChannel, which fake-timer installations (Playwright's CDP clock in
47
- // Next's own e2e suites) never intercept. Under an installed fake clock the
48
- // rAF path loses its "before the next idle callback" ordering and
49
- // Suspense-boundary promotions land after a test's post-drain DOM sample.
50
- // Route the flush through a MessageChannel task so effect timing is
51
- // clock-independent, like React's.
52
- if ((process.browser || typeof window !== 'undefined') && typeof MessageChannel !== 'undefined') {
53
- const queue: (() => void)[] = [];
54
- const channel = new MessageChannel();
55
- channel.port1.onmessage = () => {
56
- for (const callback of queue.splice(0)) callback();
57
- };
58
- (options as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame =
59
- callback => {
60
- queue.push(callback);
61
- channel.port2.postMessage(null);
62
- };
63
- }
64
- }
65
-
66
- function applyReactTextSeparators(vnode: VNode) {
67
- // Server-only. `process.browser` is a client-bundle define (true) and
68
- // undefined on the server, so this body folds out of browser builds; the
69
- // `typeof window` twin keeps the runtime check for unbundled consumers.
70
- if (process.browser || typeof window !== 'undefined') return;
71
- const props = vnode.props as Record<string, unknown>;
72
- const children = props.children;
73
- if (!Array.isArray(children) || children.length < 2) return;
74
- const values = children as unknown[];
75
-
76
- const separated: unknown[] = [];
77
- let changed = false;
78
- for (let index = 0; index < values.length; index += 1) {
79
- const child = values[index];
80
- separated.push(child);
81
- if (!mayRenderText(child) || !mayRenderText(values[index + 1])) continue;
82
- separated.push({
83
- type: reactTextSeparatorSymbol,
84
- props: { UNSTABLE_comment: ' ' },
85
- });
86
- changed = true;
87
- }
88
- if (changed) props.children = separated;
89
- }
90
-
91
- function mayRenderText(value: unknown) {
92
- if (typeof value === 'string' || typeof value === 'number') return true;
93
- if (!value || typeof value !== 'object' || !('type' in value)) return false;
94
- return typeof (value as VNode).type !== 'string';
95
- }
2
+ import { type ComponentChildren, type ComponentType, type VNode } from 'preact';
3
+ import { setSuspenseParity } from './parity';
4
+ import { use } from './use';
5
+ import { useActionState } from './action-state';
6
+ import { useOptimistic, useTransition } from './hooks-extra';
7
+
8
+ // Full react shim: preact/compat plus the React 19 surface. The vnode parity pass lives in ./parity
9
+ // (compat-free, shared with the lite client shim); the suspense-dependent parity below registers into
10
+ // it here, so it only ships when this module (and preact/compat with it) is in the bundle.
11
+
12
+ setSuspenseParity({
13
+ beforeThrowSafety: applyAsyncClientComponent,
14
+ afterThrowSafety: applyThenableChildren,
15
+ });
16
+
17
+ export { setReactCompatActive } from './parity';
18
+ export { use, withUseThenableState, type UseThenableState } from './use';
19
+ export {
20
+ useActionState,
21
+ consumeActionStateOverride,
22
+ type FormStateDispatchMeta,
23
+ } from './action-state';
24
+ export { useOptimistic, useTransition } from './hooks-extra';
96
25
 
97
26
  function applyThenableChildren(vnode: VNode) {
98
27
  const props = vnode.props as Record<string, unknown>;
@@ -152,29 +81,9 @@ function applyAsyncClientComponent(vnode: VNode) {
152
81
  vnode.type = wrapper;
153
82
  }
154
83
 
155
- function applyPrimitiveThrowSafety(vnode: VNode) {
156
- // Client-only: this exists to stop a thrown undefined/null from crashing preact's OWN diff.js `e.then`
157
- // suspense check during real preact diffing. The server's resolveServerTree is a custom RSC-style tree
158
- // walker, not preact's diff - it never hits that crash, and wrapping component references there risks
159
- // losing the symbol-keyed metadata resolveServerTree branches on.
160
- if (!process.browser && typeof window === 'undefined') return;
161
- if (typeof vnode.type !== 'function') return;
162
- vnode.type = wrapComponentForPrimitiveThrows(vnode.type);
163
- }
164
-
165
- function applyReact19RefProp(vnode: VNode) {
166
- if (!vnode.ref || typeof vnode.type !== 'function') return;
167
- const component = vnode.type as RefCompatibleComponent;
168
- if (component.prototype?.render || component.$$typeof === reactForwardRefSymbol) return;
169
-
170
- vnode.props = { ...(vnode.props as Record<string, unknown>), ref: vnode.ref } as unknown as typeof vnode.props;
171
- vnode.ref = null;
172
- }
173
-
174
- // React 19 APIs preact/compat lacks - use(), useActionState, useOptimistic (function declarations below,
175
- // hoisted) - must ALSO live on the default export: app code does `import React from 'react';
176
- // React.use(...)`, not only named imports. server.ts spreads this default into ReactServer, so the
177
- // augmentation covers both layers.
84
+ // React 19 APIs preact/compat lacks - use(), useActionState, useOptimistic (imported above) - must ALSO
85
+ // live on the default export: app code does `import React from 'react'; React.use(...)`, not only named
86
+ // imports. server.ts spreads this default into ReactServer, so the augmentation covers both layers.
178
87
  export default Object.assign(ReactCompat, { use, useActionState, useOptimistic, useTransition });
179
88
 
180
89
  export const Children = ReactCompat.Children;
@@ -197,7 +106,13 @@ export const hydrate = ReactCompat.hydrate;
197
106
  export const isFragment = ReactCompat.isFragment;
198
107
  export const isMemo = ReactCompat.isMemo;
199
108
  export const isValidElement = ReactCompat.isValidElement;
200
- export const lazy = ReactCompat.lazy;
109
+ // preact's lazy returns a bare function with no react.lazy $$typeof; tag it so
110
+ // validation (e.g. <Link legacyBehavior>) can recognize lazy components.
111
+ export const lazy: typeof ReactCompat.lazy = loader => {
112
+ const component = ReactCompat.lazy(loader);
113
+ (component as unknown as Record<symbol, boolean>)[Symbol.for('pnext.lazy')] = true;
114
+ return component;
115
+ };
201
116
  export const memo = ReactCompat.memo;
202
117
  export const render = ReactCompat.render;
203
118
  export const startTransition = ReactCompat.startTransition;
@@ -218,305 +133,3 @@ export const useRef = ReactCompat.useRef;
218
133
  export const useState = ReactCompat.useState;
219
134
  export const useSyncExternalStore = ReactCompat.useSyncExternalStore;
220
135
  export const version = ReactCompat.version;
221
-
222
- // True when a preact hooks call would succeed (a component render is in flight). preact fires
223
- // options._render just before invoking a component and options.diffed after it commits; between the two,
224
- // hooks have a current component. Server-side tree resolution invokes some client components as plain
225
- // functions, outside any preact render, where hooks would throw - useActionState/useOptimistic fall back
226
- // to SSR-equivalent static values there instead.
227
- let renderInFlight = false;
228
-
229
- function hooksUsable(): boolean {
230
- return renderInFlight;
231
- }
232
-
233
- {
234
- const anyOptions = options as unknown as Record<string, unknown>;
235
- const previousRender = anyOptions.__r as ((vnode: VNode) => void) | undefined;
236
- anyOptions.__r = (vnode: VNode) => {
237
- renderInFlight = true;
238
- previousRender?.(vnode);
239
- };
240
- const previousDiffed = options.diffed?.bind(options);
241
- options.diffed = vnode => {
242
- renderInFlight = false;
243
- previousDiffed?.(vnode);
244
- };
245
- }
246
-
247
- /**
248
- * React 19's useActionState, on preact hooks. Returns [state, dispatch, isPending]; dispatch(payload)
249
- * runs `action(prevState, payload)` (async ok) and swaps the state when it settles. The returned dispatch
250
- * is also valid as a `<form action={...}>` value - the pnext client runtime intercepts function form
251
- * actions and calls them with the form's FormData. Queueing follows React: concurrent dispatches chain
252
- * in order against the latest settled state rather than racing.
253
- */
254
- export function useActionState<State, Payload = FormData>(
255
- action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
256
- initialState: Awaited<State>,
257
- _permalink?: string,
258
- ): [state: Awaited<State>, dispatch: (payload: Payload) => void, isPending: boolean] {
259
- // Server resolve may invoke a client component as a plain function (no
260
- // preact render context, so hooks throw). SSR output for useActionState is
261
- // always the (possibly progressively-updated) initial state with a dispatch
262
- // that only works after hydration; fall back to exactly that. The dispatch
263
- // carries form-state metadata so SSR can progressively enhance
264
- // <form action={dispatch}>.
265
- if (!hooksUsable()) {
266
- const staticInitial = (consumeActionStateOverride() ?? { value: initialState })
267
- .value as Awaited<State>;
268
- const staticDispatch = (payload: Payload) =>
269
- void Promise.resolve(action(staticInitial, payload));
270
- tagFormStateDispatch(staticDispatch, action, staticInitial, _permalink);
271
- return [staticInitial, staticDispatch, false];
272
- }
273
- const initialRef = ReactCompat.useRef<{ value: Awaited<State> } | null>(null);
274
- if (!initialRef.current) {
275
- // A progressive (no-JS) submission re-renders the page with the action's
276
- // result as the form state; the server (and the inline hydration script)
277
- // publish it via a consumed-once global override.
278
- initialRef.current = {
279
- value: (consumeActionStateOverride() ?? { value: initialState }).value as Awaited<State>,
280
- };
281
- }
282
- const [state, setState] = ReactCompat.useState<Awaited<State>>(initialRef.current.value);
283
- const [pending, setPending] = ReactCompat.useState(0);
284
- const lastSettled = ReactCompat.useRef<Awaited<State>>(initialRef.current.value);
285
- const chain = ReactCompat.useRef<Promise<unknown>>(Promise.resolve());
286
- // A rejected action must surface to the nearest error boundary during an actual preact render (only
287
- // diff() wraps component calls in the getDerivedStateFromError/componentDidCatch try/catch).
288
- // preact/hooks' setState invokes a functional updater EAGERLY at call time, to bail out on an unchanged
289
- // value, rather than deferring it to the render - so `setState(() => { throw error })` from inside an
290
- // async .catch handler throws immediately in that microtask, outside any render call stack and outside
291
- // any try/catch, producing an unhandled rejection instead of reaching the boundary. Stash the error in
292
- // a ref and force a re-render instead; the throw then happens inside this hook's own render call.
293
- const pendingError = ReactCompat.useRef<{ error: unknown } | null>(null);
294
- const [, forceRender] = ReactCompat.useState(0);
295
-
296
- const dispatch = ReactCompat.useCallback(
297
- (payload: Payload) => {
298
- setPending(count => count + 1);
299
- // Chain in dispatch order against the latest settled state. A rejected
300
- // action leaves the previous state in place (matching React, where the
301
- // error propagates to the nearest error boundary via the transition) and
302
- // must not poison the queue for later dispatches.
303
- chain.current = chain.current.then(async () => {
304
- try {
305
- const redirectsBefore = actionRedirectCount();
306
- const next = (await action(lastSettled.current, payload));
307
- // A redirect renders the destination's initial form state even when
308
- // the shared layout island itself survives the navigation.
309
- if (next === undefined && actionRedirectCount() !== redirectsBefore) {
310
- lastSettled.current = initialRef.current!.value;
311
- setState(() => initialRef.current!.value);
312
- return;
313
- }
314
- lastSettled.current = next;
315
- setState(() => next);
316
- } finally {
317
- setPending(count => count - 1);
318
- }
319
- });
320
- // React propagates action errors to the nearest error boundary (they
321
- // are not catchable at the dispatch site). Stash it and force a render:
322
- // a class error boundary in the tree catches the throw below; without
323
- // one the uncaught render error reaches the window 'error' event, where
324
- // the compat entry's error.js overlay picks it up.
325
- chain.current = chain.current.catch(error => {
326
- if (!process.browser && typeof window === 'undefined') return;
327
- pendingError.current = { error };
328
- forceRender(count => count + 1);
329
- });
330
- },
331
- [action],
332
- );
333
-
334
- if (pendingError.current) {
335
- const { error } = pendingError.current;
336
- pendingError.current = null;
337
- throw error;
338
- }
339
-
340
- tagFormStateDispatch(dispatch, action, state, _permalink);
341
- return [state, dispatch, pending > 0];
342
- }
343
-
344
- /**
345
- * Form-state metadata attached to a useActionState dispatch so the server
346
- * renderer can progressively enhance <form action={dispatch}>: the underlying
347
- * action (for its wire id), the state at render time (posted back in a hidden
348
- * field so the server can run `action(state, formData)` without JS), and the
349
- * optional permalink target.
350
- */
351
- export interface FormStateDispatchMeta {
352
- action: (state: never, payload: never) => unknown;
353
- state: unknown;
354
- permalink?: string;
355
- }
356
-
357
- function tagFormStateDispatch(
358
- dispatch: (payload: never) => void,
359
- action: (state: never, payload: never) => unknown,
360
- state: unknown,
361
- permalink?: string,
362
- ) {
363
- (dispatch as unknown as { $$pnextFormState?: FormStateDispatchMeta }).$$pnextFormState = {
364
- action: action,
365
- state,
366
- ...(permalink !== undefined ? { permalink } : {}),
367
- };
368
- }
369
-
370
- /**
371
- * Consumed-once initial-state override for useActionState, published either by
372
- * the server before re-rendering a page for a progressive form submission, or
373
- * by the inline hydration script that mirrors it to the client.
374
- */
375
- /** Redirect counter the action-client runtime bumps (see markActionRedirected). */
376
- function actionRedirectCount(): number {
377
- return (globalThis as { __pnextActionRedirects?: number }).__pnextActionRedirects ?? 0;
378
- }
379
-
380
- export function consumeActionStateOverride(): { value: unknown } | undefined {
381
- const holder = globalThis as { __PNEXT_ACTION_STATE__?: unknown };
382
- if (!('__PNEXT_ACTION_STATE__' in holder)) return undefined;
383
- const override = holder.__PNEXT_ACTION_STATE__;
384
- if (
385
- override !== null &&
386
- typeof override === 'object' &&
387
- 'skip' in override &&
388
- typeof override.skip === 'number' &&
389
- override.skip > 0
390
- ) {
391
- override.skip--;
392
- return undefined;
393
- }
394
- delete holder.__PNEXT_ACTION_STATE__;
395
- return {
396
- value:
397
- override !== null && typeof override === 'object' && 'value' in override
398
- ? override.value
399
- : override,
400
- };
401
- }
402
-
403
- /**
404
- * React 19's useTransition, on preact hooks. preact/compat's own useTransition is a no-op stub that never
405
- * tracks pending state, so an async transition callback's in-flight window is invisible. Track it with a
406
- * counter: startTransition(cb) runs cb, and when cb returns a thenable holds `isPending` true until it
407
- * settles. Concurrent transitions overlap, each incrementing and decrementing, so isPending stays true
408
- * while ANY is in flight - matching React.
409
- */
410
- export function useTransition(): [boolean, (callback: () => void | Promise<void>) => void] {
411
- if (!hooksUsable()) return [false, callback => void callback()];
412
- const [pending, setPending] = ReactCompat.useState(0);
413
- const startTransition = ReactCompat.useCallback((callback: () => void | Promise<void>) => {
414
- const mpaBefore = (globalThis as { __pnextMpaNavigation?: number }).__pnextMpaNavigation ?? 0;
415
- let result: void | Promise<void>;
416
- try {
417
- result = callback();
418
- } catch {
419
- return;
420
- }
421
- const mpaAfter = (globalThis as { __pnextMpaNavigation?: number }).__pnextMpaNavigation ?? 0;
422
- if (mpaAfter !== mpaBefore) {
423
- setPending(count => count + 1);
424
- return;
425
- }
426
- if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
427
- setPending(count => count + 1);
428
- void Promise.resolve(result).finally(() => setPending(count => count - 1));
429
- }
430
- }, []);
431
- return [pending > 0, startTransition];
432
- }
433
-
434
- /**
435
- * React 19's useOptimistic, on preact hooks (simplified: the optimistic value
436
- * resets whenever the passthrough (base) value changes on a rerender, which is
437
- * when the real state has caught up).
438
- */
439
- export function useOptimistic<State, Payload = State>(
440
- passthrough: State,
441
- reducer?: (state: State, payload: Payload) => State,
442
- ): [State, (payload: Payload) => void] {
443
- if (!hooksUsable()) return [passthrough, () => undefined];
444
- const [optimistic, setOptimistic] = ReactCompat.useState<{ value: State } | null>(null);
445
- const lastBase = ReactCompat.useRef(passthrough);
446
- if (lastBase.current !== passthrough) {
447
- lastBase.current = passthrough;
448
- if (optimistic) setOptimistic(null);
449
- }
450
- const dispatch = ReactCompat.useCallback(
451
- (payload: Payload) => {
452
- setOptimistic(current => ({
453
- value: reducer
454
- ? reducer(current ? current.value : lastBase.current, payload)
455
- : (payload as unknown as State),
456
- }));
457
- },
458
- [reducer],
459
- );
460
- return [optimistic ? optimistic.value : passthrough, dispatch];
461
- }
462
-
463
- interface TrackedThenable<T> extends PromiseLike<T> {
464
- status?: 'pending' | 'fulfilled' | 'rejected';
465
- value?: T;
466
- reason?: unknown;
467
- }
468
-
469
- // Replay state for use() in server components: the renderer replays a component
470
- // after a thrown thenable settles, and use() call N must resolve to the thenable
471
- // recorded on the previous attempt (the rerun creates a fresh promise).
472
- export interface UseThenableState {
473
- thenables: TrackedThenable<unknown>[];
474
- }
475
-
476
- let activeUseState: UseThenableState | null = null;
477
- let activeUseIndex = 0;
478
-
479
- export function withUseThenableState<T>(state: UseThenableState, run: () => T): T {
480
- const previousState = activeUseState;
481
- const previousIndex = activeUseIndex;
482
- activeUseState = state;
483
- activeUseIndex = 0;
484
- try {
485
- return run();
486
- } finally {
487
- activeUseState = previousState;
488
- activeUseIndex = previousIndex;
489
- }
490
- }
491
-
492
- // React 19's use(): unwrap a thenable via suspense, or read a context.
493
- export function use<T>(usable: PromiseLike<T> | Parameters<typeof ReactCompat.useContext>[0]): T {
494
- if (usable && typeof (usable as PromiseLike<T>).then === 'function') {
495
- let thenable = usable as TrackedThenable<T>;
496
- if (activeUseState) {
497
- const index = activeUseIndex++;
498
- const existing = activeUseState.thenables[index];
499
- if (existing) thenable = existing as TrackedThenable<T>;
500
- else activeUseState.thenables[index] = thenable;
501
- }
502
- if (thenable.status === 'fulfilled') return thenable.value as T;
503
- if (thenable.status === 'rejected') throw thenable.reason;
504
- if (thenable.status !== 'pending') {
505
- thenable.status = 'pending';
506
- thenable.then(
507
- value => {
508
- thenable.status = 'fulfilled';
509
- thenable.value = value;
510
- },
511
- reason => {
512
- thenable.status = 'rejected';
513
- thenable.reason = reason;
514
- },
515
- );
516
- }
517
- // React use() protocol: suspend by throwing the thenable itself.
518
- // eslint-disable-next-line @typescript-eslint/only-throw-error
519
- throw thenable;
520
- }
521
- return ReactCompat.useContext(usable as Parameters<typeof ReactCompat.useContext>[0]) as T;
522
- }
@@ -24,16 +24,23 @@ interface Holder {
24
24
  }
25
25
 
26
26
  const collectorKey = Symbol.for('pnext.serverInsertedHTML');
27
- let getRequestScope: (() => Record<PropertyKey, unknown> | undefined) | undefined;
27
+ type RequestScopeGetter = () => Record<PropertyKey, unknown> | undefined;
28
+ // globalThis-anchored: the prebundled server entry inlines its own copy of this module.
29
+ const SCOPE_GETTER_KEY = Symbol.for('pnext.serverInsertedHTMLScope');
28
30
 
29
- export function setServerInsertedHTMLScope(
30
- getter: () => Record<PropertyKey, unknown> | undefined,
31
- ): void {
32
- getRequestScope = getter;
31
+ export function setServerInsertedHTMLScope(getter: RequestScopeGetter): void {
32
+ (globalThis as Record<PropertyKey, unknown>)[SCOPE_GETTER_KEY] = getter;
33
+ }
34
+
35
+ function requestScope(): Record<PropertyKey, unknown> | undefined {
36
+ const getter = (globalThis as Record<PropertyKey, unknown>)[SCOPE_GETTER_KEY] as
37
+ | RequestScopeGetter
38
+ | undefined;
39
+ return getter?.();
33
40
  }
34
41
 
35
42
  function collector(): { callbacks: InsertCallback[]; scoped: boolean } {
36
- const scope = getRequestScope?.();
43
+ const scope = requestScope();
37
44
  if (scope) {
38
45
  return {
39
46
  callbacks: (scope[collectorKey] ??= []) as InsertCallback[],
@@ -81,7 +88,7 @@ export function renderServerInsertedHTML(): string {
81
88
 
82
89
  /** Discard any registered callbacks without rendering (error / reset paths). */
83
90
  export function clearServerInsertedHTML(): void {
84
- const scope = getRequestScope?.();
91
+ const scope = requestScope();
85
92
  if (scope) scope[collectorKey] = [];
86
93
  else (globalThis as Holder).__PNEXT_SERVER_INSERTED_HTML__ = [];
87
94
  }
@@ -0,0 +1,72 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import type { Context } from 'preact';
3
+
4
+ interface TrackedThenable<T> extends PromiseLike<T> {
5
+ status?: 'pending' | 'fulfilled' | 'rejected';
6
+ value?: T;
7
+ reason?: unknown;
8
+ }
9
+
10
+ // Replay state for use() in server components: the renderer replays a component
11
+ // after a thrown thenable settles, and use() call N must resolve to the thenable
12
+ // recorded on the previous attempt (the rerun creates a fresh promise).
13
+ export interface UseThenableState {
14
+ thenables: TrackedThenable<unknown>[];
15
+ }
16
+
17
+ // globalThis-anchored: the prebundled server entry inlines its own copy of this module;
18
+ // a replay in one copy must be visible to use() in the other or retries loop forever.
19
+ interface UseReplayState {
20
+ active: UseThenableState | null;
21
+ index: number;
22
+ }
23
+ const REPLAY_STATE_KEY = Symbol.for('pnext.useReplayState');
24
+ const replayState = ((globalThis as Record<PropertyKey, unknown>)[REPLAY_STATE_KEY] ??= {
25
+ active: null,
26
+ index: 0,
27
+ }) as UseReplayState;
28
+
29
+ export function withUseThenableState<T>(state: UseThenableState, run: () => T): T {
30
+ const previousState = replayState.active;
31
+ const previousIndex = replayState.index;
32
+ replayState.active = state;
33
+ replayState.index = 0;
34
+ try {
35
+ return run();
36
+ } finally {
37
+ replayState.active = previousState;
38
+ replayState.index = previousIndex;
39
+ }
40
+ }
41
+
42
+ // React 19's use(): unwrap a thenable via suspense, or read a context.
43
+ export function use<T>(usable: PromiseLike<T> | Context<T>): T {
44
+ if (usable && typeof (usable as PromiseLike<T>).then === 'function') {
45
+ let thenable = usable as TrackedThenable<T>;
46
+ if (replayState.active) {
47
+ const index = replayState.index++;
48
+ const existing = replayState.active.thenables[index];
49
+ if (existing) thenable = existing as TrackedThenable<T>;
50
+ else replayState.active.thenables[index] = thenable;
51
+ }
52
+ if (thenable.status === 'fulfilled') return thenable.value as T;
53
+ if (thenable.status === 'rejected') throw thenable.reason;
54
+ if (thenable.status !== 'pending') {
55
+ thenable.status = 'pending';
56
+ thenable.then(
57
+ value => {
58
+ thenable.status = 'fulfilled';
59
+ thenable.value = value;
60
+ },
61
+ reason => {
62
+ thenable.status = 'rejected';
63
+ thenable.reason = reason;
64
+ },
65
+ );
66
+ }
67
+ // React use() protocol: suspend by throwing the thenable itself.
68
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
69
+ throw thenable;
70
+ }
71
+ return useContext(usable as Context<T>);
72
+ }
@@ -43,6 +43,7 @@ import { serverBundleTargetForRuntime } from '../../runtime/server';
43
43
  import {
44
44
  renderActionReturnElement,
45
45
  renderGlobalNotFoundResponse,
46
+ renderNotFoundForRoute,
46
47
  renderPage,
47
48
  renderPageResponse,
48
49
  } from '../../render';
@@ -163,6 +164,7 @@ export function registerActionExtensions(_config: ResolvedConfig): void {
163
164
  interface ActionStepState {
164
165
  actions: ActionManifestEntry[];
165
166
  actionSources?: string[];
167
+ actionImporters?: string[];
166
168
  deferred?: Promise<void>;
167
169
  }
168
170
 
@@ -175,6 +177,7 @@ const runActionBuildStep: BuildStep = async (ctx: BuildStepContext): Promise<voi
175
177
  ? ctx.log.step('action discovery', () => discoverActions(config))
176
178
  : discoverActions(config));
177
179
  state.actionSources = actionSourceKeys(config, discovery);
180
+ state.actionImporters = [...discovery.actionImporters];
178
181
  // Compiling those modules to server bundles produces manifest entries nothing
179
182
  // upstream of the build manifest reads, so it is handed back to run under the
180
183
  // client stage instead of ahead of it.
@@ -436,14 +439,31 @@ function buildServeActionOptions(
436
439
  const options: ServeActionOptions = {
437
440
  ...(dev ? { dev: true } : {}),
438
441
  importModule,
442
+ // skipNoindex: Next omits the fallback robots meta for an action-triggered
443
+ // not-found (app-render's NonIndex), keeping the page's own robots value.
439
444
  renderNotFound: async notFoundRequest =>
440
- renderGlobalNotFoundResponse({
441
- config,
442
- url: new URL(notFoundRequest.url),
443
- // GET conversion: the page renderer refuses non-GET requests.
444
- request: new Request(notFoundRequest.url, { headers: notFoundRequest.headers }),
445
- ...(dev ? { dev: true, devImportVersion: version } : {}),
446
- }),
445
+ actionTarget?.route.kind === 'page'
446
+ ? renderNotFoundForRoute(
447
+ {
448
+ config,
449
+ route: actionTarget.route,
450
+ params: actionTarget.params,
451
+ url: new URL(notFoundRequest.url),
452
+ // GET conversion: the page renderer refuses non-GET requests.
453
+ request: new Request(notFoundRequest.url, { headers: notFoundRequest.headers }),
454
+ ...(dev ? { dev: true, devImportVersion: version } : {}),
455
+ },
456
+ { skipNoindex: true },
457
+ )
458
+ : renderGlobalNotFoundResponse(
459
+ {
460
+ config,
461
+ url: new URL(notFoundRequest.url),
462
+ request: new Request(notFoundRequest.url, { headers: notFoundRequest.headers }),
463
+ ...(dev ? { dev: true, devImportVersion: version } : {}),
464
+ },
465
+ { skipNoindex: true },
466
+ ),
447
467
  renderPageForFormState: async (formRequest, state, actionId) => {
448
468
  if (actionTarget?.route.kind !== 'page') return null;
449
469
  const formUrl = new URL(formRequest.url);