@bleedingdev/modern-js-renderer-octane 3.9.3-ultramodern.7 → 3.9.3-ultramodern.9

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 (96) hide show
  1. package/dist/cjs/client.js +56 -6
  2. package/dist/cjs/entry-application.js +7 -2
  3. package/dist/cjs/entry-client.js +3 -0
  4. package/dist/cjs/entry-server.js +13 -5
  5. package/dist/cjs/federation.js +108 -6
  6. package/dist/cjs/i18n/I18nProvider.js +6 -14
  7. package/dist/cjs/i18n/LocalizedLink.js +24 -33
  8. package/dist/cjs/i18n/index.js +0 -3
  9. package/dist/cjs/i18n-external-snapshot.js +645 -0
  10. package/dist/cjs/index.js +4 -4
  11. package/dist/cjs/manifest.js +2 -2
  12. package/dist/cjs/plugin/compiler.js +2 -5
  13. package/dist/cjs/plugin/index.js +2 -2
  14. package/dist/cjs/plugin/profile.js +12 -10
  15. package/dist/cjs/router-client.js +1 -1
  16. package/dist/cjs/router-external-snapshot.js +263 -0
  17. package/dist/cjs/router-handler.js +11 -8
  18. package/dist/cjs/router-server.js +3 -11
  19. package/dist/cjs/router.js +1 -1
  20. package/dist/cjs/routes.js +11 -17
  21. package/dist/cjs/server.js +28 -7
  22. package/dist/cjs/worker-federation-context.js +63 -0
  23. package/dist/cjs/worker-federation-contexts.js +97 -0
  24. package/dist/cjs/worker-federation.js +97 -0
  25. package/dist/esm/client.mjs +56 -6
  26. package/dist/esm/entry-application.mjs +7 -2
  27. package/dist/esm/entry-client.mjs +3 -0
  28. package/dist/esm/entry-server.mjs +11 -6
  29. package/dist/esm/federation.mjs +110 -8
  30. package/dist/esm/i18n/I18nProvider.mjs +7 -15
  31. package/dist/esm/i18n/LocalizedLink.mjs +24 -30
  32. package/dist/esm/i18n/index.mjs +1 -1
  33. package/dist/esm/i18n-external-snapshot.mjs +576 -0
  34. package/dist/esm/index.mjs +4 -4
  35. package/dist/esm/manifest.mjs +2 -2
  36. package/dist/esm/router-client.mjs +1 -1
  37. package/dist/esm/router-external-snapshot.mjs +216 -0
  38. package/dist/esm/router-handler.mjs +11 -8
  39. package/dist/esm/router-server.mjs +0 -1
  40. package/dist/esm/router.mjs +2 -2
  41. package/dist/esm/routes.mjs +11 -17
  42. package/dist/esm/server.mjs +24 -3
  43. package/dist/esm/worker-federation-context.mjs +19 -0
  44. package/dist/esm/worker-federation-contexts.mjs +47 -0
  45. package/dist/esm/worker-federation.mjs +59 -0
  46. package/dist/esm-node/manifest.mjs +2 -2
  47. package/dist/esm-node/plugin/compiler.mjs +2 -5
  48. package/dist/esm-node/plugin/index.mjs +2 -2
  49. package/dist/esm-node/plugin/profile.mjs +12 -10
  50. package/dist/types/client.d.ts +5 -1
  51. package/dist/types/entry-client.d.ts +2 -0
  52. package/dist/types/entry-server.d.ts +4 -1
  53. package/dist/types/federation.d.ts +2 -0
  54. package/dist/types/i18n/LocalizedLink.d.ts +5 -32
  55. package/dist/types/i18n/context.d.ts +1 -1
  56. package/dist/types/i18n/index.d.ts +1 -1
  57. package/dist/types/i18n-external-snapshot.d.ts +43 -0
  58. package/dist/types/index.d.ts +4 -4
  59. package/dist/types/manifest.d.ts +2 -2
  60. package/dist/types/router-external-snapshot.d.ts +10 -0
  61. package/dist/types/router-server.d.ts +0 -2
  62. package/dist/types/server.d.ts +6 -0
  63. package/dist/types/worker-federation-context.d.ts +34 -0
  64. package/dist/types/worker-federation-contexts.d.ts +25 -0
  65. package/dist/types/worker-federation.d.ts +20 -0
  66. package/package.json +84 -22
  67. package/src/client.ts +99 -16
  68. package/src/entry-application.ts +7 -3
  69. package/src/entry-client.ts +10 -0
  70. package/src/entry-server.ts +20 -9
  71. package/src/federation.ts +159 -15
  72. package/src/i18n/I18nProvider.ts +11 -20
  73. package/src/i18n/LocalizedLink.ts +46 -72
  74. package/src/i18n/context.ts +1 -1
  75. package/src/i18n/index.ts +1 -5
  76. package/src/i18n-external-snapshot.ts +803 -0
  77. package/src/index.ts +4 -4
  78. package/src/manifest.ts +2 -2
  79. package/src/plugin/compiler-manifest.cjs +27 -1
  80. package/src/plugin/compiler.ts +6 -14
  81. package/src/plugin/index.ts +4 -2
  82. package/src/plugin/profile.ts +14 -10
  83. package/src/router-client.ts +1 -1
  84. package/src/router-external-snapshot.ts +306 -0
  85. package/src/router-handler.ts +11 -8
  86. package/src/router-server.ts +0 -2
  87. package/src/router.ts +2 -2
  88. package/src/routes.ts +28 -26
  89. package/src/server.ts +45 -5
  90. package/src/worker-federation-context.ts +51 -0
  91. package/src/worker-federation-contexts.ts +91 -0
  92. package/src/worker-federation.ts +120 -0
  93. package/dist/cjs/router-injection.js +0 -139
  94. package/dist/esm/router-injection.mjs +0 -101
  95. package/dist/types/router-injection.d.ts +0 -8
  96. package/src/router-injection.ts +0 -145
package/src/client.ts CHANGED
@@ -24,6 +24,11 @@ import {
24
24
  FederationApplication,
25
25
  type OctaneFederationScope,
26
26
  } from './federation-context';
27
+ import {
28
+ type OctaneWorkerFederationFactory,
29
+ type OctaneWorkerFederationScope,
30
+ WorkerFederationApplication,
31
+ } from './worker-federation-context';
27
32
 
28
33
  export type { OctaneDocumentBootstrap } from './bootstrap';
29
34
  export { readOctaneDocumentBootstrap } from './bootstrap';
@@ -50,9 +55,14 @@ export interface OctaneApplicationOptions {
50
55
  readonly signal?: AbortSignal;
51
56
  /** Hydration installs the native signal bridge before invoking this importer. */
52
57
  readonly load: () => Promise<OctaneApplicationModule>;
53
- readonly options?: Omit<RootOptions, 'signalOwner'>;
58
+ readonly options?: Omit<RootOptions, 'signalOwner' | 'externalSnapshots'>;
54
59
  /** This browser compilation's native Module Federation runtime. */
55
60
  readonly federation?: NativeFederationBinding;
61
+ readonly workerFederation?:
62
+ | OctaneWorkerFederationScope
63
+ | OctaneWorkerFederationFactory;
64
+ /** Trusted document bootstrap identity, also present for a CSR document. */
65
+ readonly documentId?: string;
56
66
  }
57
67
 
58
68
  export interface OctaneHydrationOptions extends OctaneApplicationOptions {
@@ -296,6 +306,8 @@ function createHandle(input: {
296
306
  signal?: AbortSignal;
297
307
  onCleanupError?: (error: unknown) => void;
298
308
  federation?: OctaneFederationScope;
309
+ workerFederation?: OctaneWorkerFederationScope;
310
+ retireWorkerFederation?: () => void;
299
311
  }): OctaneApplicationHandle {
300
312
  let releaseModule = input.releaseModule;
301
313
  let disposed = false;
@@ -310,12 +322,18 @@ function createHandle(input: {
310
322
  const nextRelease = claimModuleResources(next);
311
323
  releaseModule = nextRelease;
312
324
  try {
313
- input.federation
314
- ? input.root.render(FederationApplication, {
315
- scope: input.federation,
325
+ input.workerFederation
326
+ ? input.root.render(WorkerFederationApplication, {
327
+ scope: input.workerFederation,
328
+ federation: input.federation,
316
329
  application: next,
317
330
  })
318
- : input.root.render(next.default, next.props);
331
+ : input.federation
332
+ ? input.root.render(FederationApplication, {
333
+ scope: input.federation,
334
+ application: next,
335
+ })
336
+ : input.root.render(next.default, next.props);
319
337
  previousRelease();
320
338
  } catch (error) {
321
339
  cleanupResources([previousRelease, () => handle.dispose()]);
@@ -327,6 +345,7 @@ function createHandle(input: {
327
345
  disposed = true;
328
346
  input.signal?.removeEventListener('abort', onAbort);
329
347
  cleanupResources([
348
+ () => input.retireWorkerFederation?.(),
330
349
  () => input.root.unmount(),
331
350
  () => releaseModule(),
332
351
  () => input.bridge?.dispose(),
@@ -356,6 +375,21 @@ async function startApplication(
356
375
  hydration?: { document: Document; documentId: string },
357
376
  ): Promise<OctaneApplicationHandle> {
358
377
  const errors = observeRootErrors(input.options);
378
+ const workerController = input.workerFederation
379
+ ? new AbortController()
380
+ : undefined;
381
+ let workerFederation: OctaneWorkerFederationScope | undefined;
382
+ const retireWorkerFederation = () =>
383
+ cleanupResources([
384
+ () =>
385
+ workerController?.abort(
386
+ new DOMException(
387
+ 'The Worker federation document was disposed',
388
+ 'AbortError',
389
+ ),
390
+ ),
391
+ () => workerFederation?.transport.dispose(),
392
+ ]);
359
393
  let bridge: StreamedSignalHydration | undefined;
360
394
  let root: Root | undefined;
361
395
  let releaseModule: (() => void) | undefined;
@@ -369,6 +403,7 @@ async function startApplication(
369
403
  if (cleaned) return;
370
404
  cleaned = true;
371
405
  cleanupResources([
406
+ retireWorkerFederation,
372
407
  () => root?.unmount(),
373
408
  () => releaseModule?.(),
374
409
  () => bridge?.dispose(),
@@ -376,10 +411,30 @@ async function startApplication(
376
411
  ]);
377
412
  };
378
413
  try {
379
- // Capture the selected host before asynchronous application imports.
380
- const federation = input.federation
381
- ? createFederationScope(input.federation, false)
382
- : undefined;
414
+ if (input.workerFederation && workerController) {
415
+ const signal = AbortSignal.any([
416
+ workerController.signal,
417
+ ...(input.signal ? [input.signal] : []),
418
+ ]);
419
+ const scope =
420
+ typeof input.workerFederation === 'function'
421
+ ? input.workerFederation(signal)
422
+ : input.workerFederation;
423
+ workerFederation = {
424
+ ...scope,
425
+ signal: scope.signal ? AbortSignal.any([signal, scope.signal]) : signal,
426
+ };
427
+ }
428
+ if (workerFederation && !hydration && !input.documentId)
429
+ throw new Error(
430
+ 'Worker federation requires the trusted document identity before application loading',
431
+ );
432
+ // Capture the host before imports and retain Worker SSR's provider ranges
433
+ // even when no regular Module Federation binding is configured.
434
+ const federation =
435
+ input.federation || workerFederation
436
+ ? createFederationScope(input.federation, false)
437
+ : undefined;
383
438
  if (hydration) {
384
439
  const { document, documentId } = hydration;
385
440
  bridge = bootstrapStreamedSignalHydration({
@@ -402,15 +457,41 @@ async function startApplication(
402
457
  if (bridge) {
403
458
  root = hydrateRoot(
404
459
  input.container,
405
- federation ? FederationApplication : application.default,
406
- federation ? { scope: federation, application } : application.props,
407
- { ...errors.options, signalOwner: bridge.signalOwner },
460
+ workerFederation
461
+ ? WorkerFederationApplication
462
+ : federation
463
+ ? FederationApplication
464
+ : application.default,
465
+ workerFederation
466
+ ? { scope: workerFederation, federation, application }
467
+ : federation
468
+ ? { scope: federation, application }
469
+ : application.props,
470
+ {
471
+ ...errors.options,
472
+ signalOwner: bridge.signalOwner,
473
+ externalSnapshots: { documentId: hydration!.documentId },
474
+ },
408
475
  );
409
476
  } else {
410
- root = createRoot(input.container, errors.options);
411
- federation
412
- ? root.render(FederationApplication, { scope: federation, application })
413
- : root.render(application.default, application.props);
477
+ root = createRoot(input.container, {
478
+ ...errors.options,
479
+ ...(input.documentId
480
+ ? { externalSnapshots: { documentId: input.documentId } }
481
+ : {}),
482
+ });
483
+ workerFederation
484
+ ? root.render(WorkerFederationApplication, {
485
+ scope: workerFederation,
486
+ federation,
487
+ application,
488
+ })
489
+ : federation
490
+ ? root.render(FederationApplication, {
491
+ scope: federation,
492
+ application,
493
+ })
494
+ : root.render(application.default, application.props);
414
495
  }
415
496
  errors.assertInitialRender();
416
497
  input.signal?.throwIfAborted();
@@ -421,6 +502,8 @@ async function startApplication(
421
502
  release,
422
503
  bridge,
423
504
  federation,
505
+ workerFederation,
506
+ retireWorkerFederation,
424
507
  ...(input.signal === undefined ? {} : { signal: input.signal }),
425
508
  onCleanupError: error => reportCleanupError(input, error),
426
509
  });
@@ -10,6 +10,7 @@ import {
10
10
  import { type ComponentBody, createElement } from 'octane';
11
11
  import { I18nProvider } from './i18n/I18nProvider';
12
12
  import type { I18nInstanceLike } from './i18n/types';
13
+ import { associateOctaneFileRouteSnapshot } from './router-external-snapshot';
13
14
  import {
14
15
  createFileSystemRouteTree,
15
16
  type FileSystemRouteModule,
@@ -41,8 +42,8 @@ export const octaneRouterFactory: NativeRouterFactory<AnyRoute, AnyRouter> = {
41
42
  modules as Readonly<Record<string, FileSystemRouteModule>>,
42
43
  options,
43
44
  ),
44
- router: ({ location, ...options }) =>
45
- createRouter({
45
+ router: ({ location, ...options }) => {
46
+ const router = createRouter({
46
47
  ...options,
47
48
  ...(location
48
49
  ? {
@@ -50,7 +51,10 @@ export const octaneRouterFactory: NativeRouterFactory<AnyRoute, AnyRouter> = {
50
51
  history: createMemoryHistory({ initialEntries: [location.href] }),
51
52
  }
52
53
  : {}),
53
- }),
54
+ });
55
+ associateOctaneFileRouteSnapshot(router);
56
+ return router;
57
+ },
54
58
  };
55
59
 
56
60
  interface LocalizedRouterProps {
@@ -21,11 +21,18 @@ import {
21
21
  OctaneRouterRoot,
22
22
  prepareOctaneRouterHydration,
23
23
  } from './router-client';
24
+ import type {
25
+ OctaneWorkerFederationFactory,
26
+ OctaneWorkerFederationScope,
27
+ } from './worker-federation-context';
24
28
 
25
29
  export interface OctaneClientEntryOptions
26
30
  extends NativeClientEntryOptions<NativeI18nInstance, LocalisedUrlsOption> {
27
31
  /** The native client compilation hash, `__webpack_hash__`. */
28
32
  readonly nativeHydrationBuildId: string;
33
+ readonly workerFederation?:
34
+ | OctaneWorkerFederationScope
35
+ | OctaneWorkerFederationFactory;
29
36
  }
30
37
 
31
38
  const LocalizedRouterRoot = localizedRouterRoot(OctaneRouterRoot);
@@ -38,6 +45,7 @@ export function startNativeClient(options: OctaneClientEntryOptions): void {
38
45
  readOctaneDocumentBootstrap(document, identity, nativeHydrationBuildId),
39
46
  createRouter: (application, routerOptions) =>
40
47
  createNativeRouter(application, routerOptions, octaneRouterFactory),
48
+ disposeRouter: router => router.history.destroy(),
41
49
  async prepareRouter(router, hydrating, signal) {
42
50
  // Hydration adopts the server's matches; a fresh mount loads its own.
43
51
  if (hydrating) await prepareOctaneRouterHydration(router, { signal });
@@ -49,6 +57,8 @@ export function startNativeClient(options: OctaneClientEntryOptions): void {
49
57
  identity,
50
58
  nativeHydrationBuildId,
51
59
  federation: options.federation,
60
+ workerFederation: options.workerFederation,
61
+ documentId: bootstrap?.documentId,
52
62
  signal,
53
63
  // Hydration installs the native signal bridge before this importer runs.
54
64
  load: async (): Promise<OctaneApplicationModule> => {
@@ -23,8 +23,8 @@ import {
23
23
  import { validateOctaneModuleManifest } from './manifest';
24
24
  import {
25
25
  createOctaneRequestHandler,
26
- createOctaneRouterInjection,
27
26
  createSsrStreamResponse,
27
+ getSsrStatus,
28
28
  OctaneRouterServer,
29
29
  } from './router-server';
30
30
  import { renderOctaneApplication, renderOctaneCSRDocument } from './server';
@@ -52,10 +52,12 @@ export function withOctaneDocumentFields(
52
52
  : policy;
53
53
  }
54
54
 
55
- export type OctaneServerEntryOptions = NativeServerEntryOptions<
56
- NativeI18nInstance,
57
- LocalisedUrlsOption
58
- >;
55
+ export { createNativeWorkerFederationRuntime } from './worker-federation';
56
+
57
+ export interface OctaneServerEntryOptions
58
+ extends NativeServerEntryOptions<NativeI18nInstance, LocalisedUrlsOption> {
59
+ readonly workerFederationLocale?: string;
60
+ }
59
61
 
60
62
  const LocalizedRouterServer = localizedRouterRoot(OctaneRouterServer);
61
63
 
@@ -77,16 +79,23 @@ export function createNativeServerEntry(
77
79
  createNativeRouter(application, routerOptions, octaneRouterFactory),
78
80
  renderCSR: ({ session }, document) =>
79
81
  renderOctaneCSRDocument({ session, document }),
80
- renderComponent: ({ session }, component, document, i18n) =>
82
+ renderComponent: (
83
+ { session, workerFederation },
84
+ component,
85
+ document,
86
+ i18n,
87
+ ) =>
81
88
  renderOctaneApplication({
82
89
  session,
83
90
  App: componentView(component, i18n) as ServerRenderNode,
84
91
  document,
85
92
  federation: options.federation,
93
+ workerFederation,
94
+ workerFederationLocale: options.workerFederationLocale,
86
95
  }),
87
96
  async renderRoutes({
88
97
  request,
89
- context: { session },
98
+ context: { session, workerFederation },
90
99
  router,
91
100
  outcomes,
92
101
  document,
@@ -100,7 +109,7 @@ export function createNativeServerEntry(
100
109
  serialization: { forbiddenValues },
101
110
  })(async ({ router, responseHeaders }) => {
102
111
  const metadata = mergeDataResponseMetadata(outcomes, {
103
- status: router.state.statusCode ?? 200,
112
+ status: getSsrStatus(router),
104
113
  });
105
114
  // Route headers() take precedence over loader singleton fields; list
106
115
  // fields keep both.
@@ -127,7 +136,9 @@ export function createNativeServerEntry(
127
136
  }),
128
137
  document,
129
138
  federation: options.federation,
130
- injection: createOctaneRouterInjection(router, session),
139
+ workerFederation,
140
+ workerFederationLocale: options.workerFederationLocale,
141
+ router,
131
142
  });
132
143
  return createSsrStreamResponse(router, rendered);
133
144
  });
package/src/federation.ts CHANGED
@@ -1,21 +1,31 @@
1
- import {
2
- loadFederatedModule,
3
- remoteBrowserAssets,
4
- } from '@modern-js/renderer-core/federation';
1
+ import { loadFederatedModule } from '@modern-js/renderer-core/federation';
5
2
  import {
6
3
  type ComponentBody,
7
4
  createElement,
5
+ decodeExternalSnapshot,
6
+ externalSnapshotBoundary,
7
+ hookSlots,
8
8
  lazy,
9
9
  type OctaneNode,
10
10
  preinit,
11
+ publisherBoundary,
11
12
  Suspense,
13
+ serializeExternalSnapshotRequest,
12
14
  useContext,
15
+ useEffect,
13
16
  } from 'octane';
14
17
  import {
15
18
  FederationContext,
16
19
  type OctaneFederationScope,
17
20
  } from './federation-context';
18
21
 
22
+ import { I18nContext } from './i18n/context';
23
+ import {
24
+ type OctaneWorkerFederationScope,
25
+ WorkerFederationContext,
26
+ } from './worker-federation-context';
27
+ import { useOctaneWorkerContextKeys } from './worker-federation-contexts';
28
+
19
29
  /** The native component shape exposed by a same-renderer Octane remote. */
20
30
  export interface FederatedModule<P extends object> {
21
31
  readonly default: ComponentBody<P>;
@@ -26,6 +36,8 @@ export interface FederatedComponentOptions {
26
36
  readonly fallback?: () => OctaneNode;
27
37
  /** A positive load limit; the server defaults to 3000ms. */
28
38
  readonly timeout?: number;
39
+ /** Explicit registered server projections required by an application remote. */
40
+ readonly contextKeys?: readonly string[];
29
41
  }
30
42
 
31
43
  function withTimeout<T>(
@@ -56,6 +68,9 @@ function withTimeout<T>(
56
68
  });
57
69
  }
58
70
 
71
+ // This library wrapper is published as plain JavaScript, outside app compilation.
72
+ const publisherRegistrationEffectSlot = Symbol(hookSlots(1));
73
+
59
74
  /**
60
75
  * Render a native Octane remote by its `remote/Expose` id or a browser loader.
61
76
  * Native Suspense owns server fallbacks, streamed styles and hydration adoption;
@@ -90,11 +105,33 @@ export function federatedComponent<P extends object = Record<string, never>>(
90
105
  };
91
106
  // Native lazy caches its module. Scope the cache to one application/response,
92
107
  // so two hosts using the same component definition cannot share a remote.
93
- const components = new WeakMap<OctaneFederationScope, ComponentBody<P>>();
108
+ const components = new WeakMap<object, ComponentBody<P>>();
109
+ const workerOwners = new WeakMap<
110
+ OctaneFederationScope,
111
+ WeakMap<OctaneWorkerFederationScope, Map<string, object>>
112
+ >();
94
113
 
95
114
  return props => {
96
115
  const context = useContext(FederationContext) ?? standalone;
97
- let Remote = components.get(context);
116
+ const worker = useContext(WorkerFederationContext);
117
+ const language = worker
118
+ ? (useContext(I18nContext)?.language ?? worker.locale)
119
+ : undefined;
120
+ const contextKeys = worker
121
+ ? useOctaneWorkerContextKeys(options.contextKeys)
122
+ : undefined;
123
+ let owner: object = context;
124
+ if (worker) {
125
+ let scopes = workerOwners.get(context);
126
+ if (!scopes) workerOwners.set(context, (scopes = new WeakMap()));
127
+ let variants = scopes.get(worker);
128
+ if (!variants) scopes.set(worker, (variants = new Map()));
129
+ const key = JSON.stringify([language, contextKeys]);
130
+ let variant = variants.get(key);
131
+ if (!variant) variants.set(key, (variant = {}));
132
+ owner = variant;
133
+ }
134
+ let Remote = components.get(owner);
98
135
  if (!Remote) {
99
136
  Remote = lazy(async () => {
100
137
  try {
@@ -103,11 +140,101 @@ export function federatedComponent<P extends object = Record<string, never>>(
103
140
  'A federatedComponent loader renders in the browser only; use a remote id to server-render it',
104
141
  );
105
142
  }
106
- const module = await withTimeout(
143
+ if (worker) {
144
+ if (!id || !language) {
145
+ throw new Error(
146
+ 'Worker federation requires a remote id and the owning entry locale',
147
+ );
148
+ }
149
+ const separator = id.indexOf('/');
150
+ const remoteName = id.slice(0, separator);
151
+ const expose = `./${id.slice(separator + 1)}`;
152
+ const controller = new AbortController();
153
+ const signal = worker.signal
154
+ ? AbortSignal.any([worker.signal, controller.signal])
155
+ : controller.signal;
156
+ const timeout =
157
+ options.timeout ?? (context.server ? 3000 : undefined);
158
+ let admitted;
159
+ try {
160
+ admitted = await withTimeout(
161
+ worker.transport.admit({
162
+ remote: remoteName,
163
+ expose,
164
+ locale: language,
165
+ signal,
166
+ }),
167
+ timeout,
168
+ id,
169
+ );
170
+ } finally {
171
+ // Release this admission waiter, including after its own deadline.
172
+ controller.abort();
173
+ }
174
+ const BrowserRemote = lazy(async () => {
175
+ const { module, assets } = await loadFederatedModule<
176
+ FederatedModule<P>
177
+ >(context.instance, id);
178
+ if (typeof module?.default !== 'function') {
179
+ throw new TypeError(
180
+ 'A federated Octane module must default-export a native component',
181
+ );
182
+ }
183
+ const component = module.default;
184
+ const Loaded: ComponentBody<P> = (props, scope, extra) => {
185
+ for (const href of assets?.css ?? []) {
186
+ preinit(href, {
187
+ as: 'style',
188
+ precedence: 'federated',
189
+ ...(context.nonce === undefined
190
+ ? {}
191
+ : { nonce: context.nonce }),
192
+ });
193
+ }
194
+ return component(props, scope, extra);
195
+ };
196
+ return { default: Loaded };
197
+ });
198
+ const boundary = externalSnapshotBoundary({
199
+ authority: admitted.coordinate.native,
200
+ component: BrowserRemote,
201
+ contextKeys,
202
+ timeoutMs: timeout,
203
+ async snapshot(request, signal) {
204
+ const response = await worker.transport.request({
205
+ remote: remoteName,
206
+ expose,
207
+ locale: language,
208
+ payload: serializeExternalSnapshotRequest(request),
209
+ signal,
210
+ });
211
+ return decodeExternalSnapshot(response.payload);
212
+ },
213
+ });
214
+ const Loaded: ComponentBody<P> = props => {
215
+ // A Worker server does not execute BrowserRemote. Register the
216
+ // exact admitted expose's extracted CSS in this active native
217
+ // render; nested snapshots carry these hints through native head.
218
+ for (const href of admitted.styles) {
219
+ preinit(href, {
220
+ as: 'style',
221
+ precedence: 'federated',
222
+ ...(context.nonce === undefined
223
+ ? {}
224
+ : { nonce: context.nonce }),
225
+ });
226
+ }
227
+ return createElement(boundary, props);
228
+ };
229
+ return { default: Loaded };
230
+ }
231
+ const { module, assets } = await withTimeout(
107
232
  Promise.resolve().then(() =>
108
233
  id
109
234
  ? loadFederatedModule<FederatedModule<P>>(context.instance, id)
110
- : (remote as () => Promise<FederatedModule<P>>)(),
235
+ : (remote as () => Promise<FederatedModule<P>>)().then(
236
+ module => ({ module, assets: undefined }),
237
+ ),
111
238
  ),
112
239
  options.timeout ?? (context.server ? 3000 : undefined),
113
240
  id ?? 'loader',
@@ -117,17 +244,34 @@ export function federatedComponent<P extends object = Record<string, never>>(
117
244
  'A federated Octane module must default-export a native component',
118
245
  );
119
246
  }
120
- const assets =
121
- id && context.instance
122
- ? remoteBrowserAssets(context.instance, id)
123
- : undefined;
124
247
  if (context.server && !assets) {
125
248
  throw new Error(
126
249
  `Cannot server-render ${id}: the host has no browser assets for this remote`,
127
250
  );
128
251
  }
129
- const component = module.default;
252
+ // Native publisher ownership admits this exact compiled component's
253
+ // signal generations. Keep the boundary in the context-scoped lazy
254
+ // cache so props and compatible hot updates retain its live owner.
255
+ const component = publisherBoundary(module.default, {
256
+ publisherKey: id ?? 'ultramodern:browser-loader',
257
+ });
130
258
  const Loaded: ComponentBody<P> = props => {
259
+ // A development runtime installs this hook on the actual owning
260
+ // host. Registration follows the native mounted lifetime; routing
261
+ // alone grants no replacement authority to a remote name.
262
+ useEffect(
263
+ () => {
264
+ if (context.server || !id || !context.instance) return;
265
+ const register = Reflect.get(
266
+ context.instance,
267
+ Symbol.for('ultramodern.federation.browser-dev-boundary.v1'),
268
+ );
269
+ if (typeof register !== 'function') return;
270
+ return register(id, module.default, component);
271
+ },
272
+ [],
273
+ publisherRegistrationEffectSlot,
274
+ );
131
275
  // Resource APIs require an active native render. Late styles travel
132
276
  // in Octane's resource carrier ahead of the remote's HTML segment.
133
277
  for (const href of assets?.css ?? []) {
@@ -144,11 +288,11 @@ export function federatedComponent<P extends object = Record<string, never>>(
144
288
  return { default: Loaded };
145
289
  } catch (error) {
146
290
  // A later mount or ErrorBoundary reset may retry a failed load.
147
- if (!context.server) components.delete(context);
291
+ if (!context.server) components.delete(owner);
148
292
  throw error;
149
293
  }
150
294
  });
151
- components.set(context, Remote);
295
+ components.set(owner, Remote);
152
296
  }
153
297
  return createElement(Suspense, {
154
298
  fallback: options.fallback,
@@ -1,6 +1,6 @@
1
- import { createElement, hookSlots, useEffect, useState } from 'octane';
1
+ import { createElement, hookSlots, useSyncExternalStore } from 'octane';
2
2
  import { I18nContext } from './context';
3
- import type { I18nContextValue, I18nInstanceLike } from './types';
3
+ import type { I18nContextValue } from './types';
4
4
 
5
5
  export interface I18nProviderProps extends Omit<I18nContextValue, 'language'> {
6
6
  children?: unknown;
@@ -22,27 +22,18 @@ export interface I18nProviderProps extends Omit<I18nContextValue, 'language'> {
22
22
  */
23
23
  // Plain TypeScript is not rewritten by the Octane compiler, so each hook call
24
24
  // site carries its own stable slot, like `OctaneRouterRoot`.
25
- const languageStateSlot = Symbol(hookSlots(1));
26
- const languageSubscriptionSlot = Symbol(hookSlots(1));
25
+ const languageStoreSlot = Symbol(hookSlots(1));
27
26
 
28
27
  export function I18nProvider(props: I18nProviderProps) {
29
- const [language, setLanguage] = useState(
30
- () => props.instance.language,
31
- languageStateSlot,
32
- );
33
-
34
- useEffect(
35
- () => {
36
- const instance: I18nInstanceLike = props.instance;
37
- setLanguage(instance.language);
38
- const handleLanguageChanged = (lng: string) => setLanguage(lng);
39
- instance.on?.('languageChanged', handleLanguageChanged);
40
- return () => {
41
- instance.off?.('languageChanged', handleLanguageChanged);
42
- };
28
+ const instance = props.instance;
29
+ const language = useSyncExternalStore(
30
+ notify => {
31
+ instance.on?.('languageChanged', notify);
32
+ return () => instance.off?.('languageChanged', notify);
43
33
  },
44
- [props.instance],
45
- languageSubscriptionSlot,
34
+ () => instance.language,
35
+ () => instance.language,
36
+ languageStoreSlot,
46
37
  );
47
38
 
48
39
  const value: I18nContextValue = {