@async/framework 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 - 2026-06-17
6
+
7
+ - Added `createBoundaryReceiver(...)` for optional out-of-order boundary patch
8
+ delivery with per-boundary sequence checks, signal/cache effect ordering,
9
+ scheduler flushing, redirects, and destroyed parent-scope filtering.
10
+
5
11
  ## 0.8.0 - 2026-06-17
6
12
 
7
13
  - Split browser and server entrypoints with `@async/framework/browser`,
package/README.md CHANGED
@@ -1050,6 +1050,47 @@ loader.swap(
1050
1050
  `swap(boundaryId, fragmentOrTemplate)` replaces the boundary contents and
1051
1051
  rescans the inserted fragment.
1052
1052
 
1053
+ When boundary patches can arrive independently, use `createBoundaryReceiver`.
1054
+ It keeps per-boundary sequence state, applies signal/cache effects before the
1055
+ HTML swap, flushes scheduled bindings, and ignores stale child patches after a
1056
+ parent scope is destroyed.
1057
+
1058
+ ```js
1059
+ import { createBoundaryReceiver } from "@async/framework/browser";
1060
+
1061
+ const receiver = createBoundaryReceiver({
1062
+ loader: runtime.loader,
1063
+ signals: runtime.signals,
1064
+ cache: runtime.browser.cache,
1065
+ scheduler: runtime.scheduler,
1066
+ router: runtime.router
1067
+ });
1068
+
1069
+ await receiver.apply({
1070
+ boundary: "product",
1071
+ seq: 1,
1072
+ signals: {
1073
+ product: { title: "Keyboard" }
1074
+ },
1075
+ cache: {
1076
+ browser: {
1077
+ "product:sku-1": { title: "Keyboard" }
1078
+ }
1079
+ },
1080
+ html: `
1081
+ <article>
1082
+ <h1 signal:text="product.title"></h1>
1083
+ <button type="button" on:click="server.cart.add(productId)">Add</button>
1084
+ </article>
1085
+ `
1086
+ });
1087
+ ```
1088
+
1089
+ Sequence numbers are tracked per boundary: `hero` patch `10` can apply before
1090
+ `reviews` patch `2`, while a later `hero` patch `9` is ignored. The receiver
1091
+ does not add transport management, a transaction log, hydration, or component
1092
+ rerendering.
1093
+
1053
1094
  ## Examples
1054
1095
 
1055
1096
  | Example | Shows |
package/browser.d.ts CHANGED
@@ -75,6 +75,7 @@ export interface Scheduler {
75
75
  afterFlush(job: () => MaybePromise<unknown>, options?: { scope?: unknown; boundary?: string; key?: string }): Cleanup;
76
76
  cancelScope(scope: unknown): this;
77
77
  markScopeDestroyed(scope: unknown): this;
78
+ isScopeDestroyed(scope: unknown): boolean;
78
79
  destroy(): void;
79
80
  inspect(): SchedulerInspection;
80
81
  }
@@ -458,6 +459,53 @@ export interface LoaderInstance {
458
459
  export type AsyncLoaderOptions = LoaderOptions;
459
460
  export type AsyncLoaderInstance = LoaderInstance;
460
461
 
462
+ export interface BoundaryPatch {
463
+ boundary: string;
464
+ seq: number;
465
+ html?: TemplateLike;
466
+ signals?: Record<string, unknown>;
467
+ cache?: { browser?: Record<string, unknown> };
468
+ redirect?: string;
469
+ error?: unknown;
470
+ parentScope?: string;
471
+ scope?: string;
472
+ meta?: Record<string, unknown>;
473
+ }
474
+
475
+ export type BoundaryApplyResult =
476
+ | { status: "applied"; boundary: string; seq: number }
477
+ | { status: "ignored-stale"; boundary: string; seq: number; lastSeq: number }
478
+ | { status: "ignored-destroyed"; boundary: string; seq: number; parentScope?: string }
479
+ | { status: "redirected"; boundary: string; seq: number; redirect: string }
480
+ | { status: "errored"; boundary: string; seq: number; error: Error };
481
+
482
+ export interface BoundaryReceiverInspection {
483
+ destroyed: boolean;
484
+ boundaries: Record<string, { lastSeq: number; applied: number; ignored: number; errored?: number; lastStatus?: BoundaryApplyResult["status"] }>;
485
+ recent: Array<{ boundary: string; seq: number; status: BoundaryApplyResult["status"]; lastSeq?: number; parentScope?: string; redirect?: string }>;
486
+ }
487
+
488
+ export interface BoundaryReceiverOptions {
489
+ loader: LoaderInstance;
490
+ signals?: SignalRegistry;
491
+ cache?: CacheRegistry;
492
+ scheduler?: Scheduler;
493
+ router?: Router;
494
+ onApply?(result: BoundaryApplyResult, patch: BoundaryPatch): void;
495
+ onIgnore?(result: BoundaryApplyResult, patch: BoundaryPatch): void;
496
+ onError?(error: Error, result: BoundaryApplyResult, patch: BoundaryPatch): void;
497
+ throwOnError?: boolean;
498
+ recentLimit?: number;
499
+ isScopeDestroyed?(scope: string): boolean;
500
+ }
501
+
502
+ export interface BoundaryReceiver {
503
+ apply(patch: BoundaryPatch): Promise<BoundaryApplyResult>;
504
+ inspect(): BoundaryReceiverInspection;
505
+ reset(boundary?: string): this;
506
+ destroy(): void;
507
+ }
508
+
461
509
  export interface RegistryStore {
462
510
  target: RuntimeTarget;
463
511
  register(type: RegistryType, id: string, value: unknown): string;
@@ -568,6 +616,7 @@ export interface AsyncNamespace extends AppHub {
568
616
  readSnapshot: typeof readSnapshot;
569
617
  attributeName: typeof attributeName;
570
618
  defineAttributeConfig: typeof defineAttributeConfig;
619
+ createBoundaryReceiver: typeof createBoundaryReceiver;
571
620
  createCacheRegistry: typeof createCacheRegistry;
572
621
  defineCache: typeof defineCache;
573
622
  component: typeof component;
@@ -603,6 +652,7 @@ export declare function defineApp(initial?: AppDefinition): AppHub;
603
652
  export declare function readSnapshot(root?: Document | Element, options?: { attributes?: AttributeConfig }): { signals?: Record<string, unknown>; cache?: { browser?: Record<string, unknown> } };
604
653
  export declare function attributeName(attributes: AttributeConfig | undefined, type: keyof NormalizedAttributeConfig, name: string): string;
605
654
  export declare function defineAttributeConfig(config?: AttributeConfig): NormalizedAttributeConfig;
655
+ export declare function createBoundaryReceiver(options: BoundaryReceiverOptions): BoundaryReceiver;
606
656
  export declare function createCacheRegistry(initialMap?: Record<string, CacheDefinition | CacheDefinitionOptions>, options?: { now?: () => number; registry?: RegistryStore; type?: "cache.browser" | "cache.server" }): CacheRegistry;
607
657
  export declare function defineCache(options?: CacheDefinitionOptions): CacheDefinition;
608
658
  export declare function component<TProps extends Record<string, unknown> = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;
package/browser.js CHANGED
@@ -2594,6 +2594,10 @@ const __schedulerModule = (() => {
2594
2594
  return api;
2595
2595
  },
2596
2596
 
2597
+ isScopeDestroyed(scope) {
2598
+ return scope !== undefined && destroyedScopes.has(scope);
2599
+ },
2600
+
2597
2601
  inspect() {
2598
2602
  const counts = {};
2599
2603
  for (const [phase, queue] of queues) {
@@ -4709,6 +4713,312 @@ const __appModule = (() => {
4709
4713
  return { defineApp, createApp, readSnapshot, Async };
4710
4714
  })();
4711
4715
 
4716
+ const __boundaryReceiverModule = (() => {
4717
+ const defaultRecentLimit = 50;
4718
+
4719
+ function createBoundaryReceiver(options = {}) {
4720
+ const loader = options.loader;
4721
+ const signals = options.signals ?? loader?.signals;
4722
+ const cache = options.cache ?? loader?.cache;
4723
+ const scheduler = options.scheduler ?? loader?.scheduler;
4724
+ const router = options.router ?? loader?.router;
4725
+ const recentLimit = options.recentLimit ?? defaultRecentLimit;
4726
+ const throwOnError = options.throwOnError === true;
4727
+ const onApply = typeof options.onApply === "function" ? options.onApply : undefined;
4728
+ const onIgnore = typeof options.onIgnore === "function" ? options.onIgnore : undefined;
4729
+ const onError = typeof options.onError === "function" ? options.onError : undefined;
4730
+ const isScopeDestroyed = typeof options.isScopeDestroyed === "function"
4731
+ ? options.isScopeDestroyed
4732
+ : (scope) => scheduler?.isScopeDestroyed?.(scope) ?? scheduler?.inspectDestroyed?.(scope) ?? false;
4733
+
4734
+ if (!loader || typeof loader.swap !== "function") {
4735
+ throw new TypeError("createBoundaryReceiver(...) requires a loader with swap(boundary, html).");
4736
+ }
4737
+ if (!Number.isInteger(recentLimit) || recentLimit < 0) {
4738
+ throw new TypeError("createBoundaryReceiver(...) recentLimit must be a non-negative integer.");
4739
+ }
4740
+
4741
+ const boundaries = new Map();
4742
+ const recent = [];
4743
+ let destroyed = false;
4744
+
4745
+ const receiver = {
4746
+ async apply(patch) {
4747
+ if (destroyed) {
4748
+ throw new Error("Boundary receiver has been destroyed.");
4749
+ }
4750
+
4751
+ const normalized = validatePatch(patch);
4752
+ const record = boundaryRecord(normalized.boundary);
4753
+ if (normalized.seq <= record.lastSeq) {
4754
+ const result = {
4755
+ status: "ignored-stale",
4756
+ boundary: normalized.boundary,
4757
+ seq: normalized.seq,
4758
+ lastSeq: record.lastSeq
4759
+ };
4760
+ record.ignored += 1;
4761
+ record.lastStatus = result.status;
4762
+ remember(result);
4763
+ onIgnore?.(result, patch);
4764
+ return result;
4765
+ }
4766
+
4767
+ if (normalized.parentScope !== undefined && isScopeDestroyed(normalized.parentScope)) {
4768
+ const result = {
4769
+ status: "ignored-destroyed",
4770
+ boundary: normalized.boundary,
4771
+ seq: normalized.seq,
4772
+ parentScope: normalized.parentScope
4773
+ };
4774
+ record.ignored += 1;
4775
+ record.lastStatus = result.status;
4776
+ remember(result);
4777
+ onIgnore?.(result, patch);
4778
+ return result;
4779
+ }
4780
+
4781
+ record.lastSeq = normalized.seq;
4782
+
4783
+ if (Object.hasOwn(normalized, "error")) {
4784
+ const error = toStableError(normalized.error);
4785
+ const result = {
4786
+ status: "errored",
4787
+ boundary: normalized.boundary,
4788
+ seq: normalized.seq,
4789
+ error
4790
+ };
4791
+ record.errored += 1;
4792
+ record.lastStatus = result.status;
4793
+ remember(result);
4794
+ onError?.(error, result, patch);
4795
+ if (throwOnError) {
4796
+ throw error;
4797
+ }
4798
+ return result;
4799
+ }
4800
+
4801
+ if (normalized.signals) {
4802
+ if (!signals || typeof signals.set !== "function") {
4803
+ throw new Error("Boundary patch includes signals, but no signal registry is available.");
4804
+ }
4805
+ for (const [path, value] of Object.entries(normalized.signals)) {
4806
+ signals.set(path, value);
4807
+ }
4808
+ }
4809
+
4810
+ if (normalized.cache?.browser) {
4811
+ if (!cache || typeof cache.restore !== "function") {
4812
+ throw new Error("Boundary patch includes browser cache, but no cache registry is available.");
4813
+ }
4814
+ cache.restore(normalized.cache.browser);
4815
+ }
4816
+
4817
+ if (normalized.html != null) {
4818
+ loader.swap(normalized.boundary, normalized.html);
4819
+ }
4820
+
4821
+ await flushScheduler(scheduler, normalized.scope);
4822
+
4823
+ if (normalized.redirect) {
4824
+ await followRedirect(normalized.redirect, router, loader);
4825
+ const result = {
4826
+ status: "redirected",
4827
+ boundary: normalized.boundary,
4828
+ seq: normalized.seq,
4829
+ redirect: normalized.redirect
4830
+ };
4831
+ record.applied += 1;
4832
+ record.lastStatus = result.status;
4833
+ remember(result);
4834
+ onApply?.(result, patch);
4835
+ return result;
4836
+ }
4837
+
4838
+ const result = {
4839
+ status: "applied",
4840
+ boundary: normalized.boundary,
4841
+ seq: normalized.seq
4842
+ };
4843
+ record.applied += 1;
4844
+ record.lastStatus = result.status;
4845
+ remember(result);
4846
+ onApply?.(result, patch);
4847
+ return result;
4848
+ },
4849
+
4850
+ inspect() {
4851
+ const snapshot = {};
4852
+ for (const [boundary, record] of boundaries) {
4853
+ snapshot[boundary] = {
4854
+ lastSeq: record.lastSeq,
4855
+ applied: record.applied,
4856
+ ignored: record.ignored,
4857
+ lastStatus: record.lastStatus
4858
+ };
4859
+ if (record.errored > 0) {
4860
+ snapshot[boundary].errored = record.errored;
4861
+ }
4862
+ }
4863
+ return {
4864
+ destroyed,
4865
+ boundaries: snapshot,
4866
+ recent: recent.map((entry) => ({ ...entry }))
4867
+ };
4868
+ },
4869
+
4870
+ reset(boundary) {
4871
+ if (boundary === undefined) {
4872
+ boundaries.clear();
4873
+ recent.length = 0;
4874
+ return receiver;
4875
+ }
4876
+ assertBoundary(boundary);
4877
+ boundaries.delete(boundary);
4878
+ for (let index = recent.length - 1; index >= 0; index -= 1) {
4879
+ if (recent[index].boundary === boundary) {
4880
+ recent.splice(index, 1);
4881
+ }
4882
+ }
4883
+ return receiver;
4884
+ },
4885
+
4886
+ destroy() {
4887
+ destroyed = true;
4888
+ boundaries.clear();
4889
+ recent.length = 0;
4890
+ }
4891
+ };
4892
+
4893
+ return receiver;
4894
+
4895
+ function boundaryRecord(boundary) {
4896
+ if (!boundaries.has(boundary)) {
4897
+ boundaries.set(boundary, {
4898
+ lastSeq: -Infinity,
4899
+ applied: 0,
4900
+ ignored: 0,
4901
+ errored: 0,
4902
+ lastStatus: undefined
4903
+ });
4904
+ }
4905
+ return boundaries.get(boundary);
4906
+ }
4907
+
4908
+ function remember(result) {
4909
+ if (recentLimit === 0) {
4910
+ return;
4911
+ }
4912
+ recent.push(toRecentEntry(result));
4913
+ while (recent.length > recentLimit) {
4914
+ recent.shift();
4915
+ }
4916
+ }
4917
+ }
4918
+
4919
+ function validatePatch(patch) {
4920
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
4921
+ throw new TypeError("receiver.apply(patch) requires a boundary patch object.");
4922
+ }
4923
+
4924
+ assertBoundary(patch.boundary);
4925
+ if (typeof patch.seq !== "number" || !Number.isFinite(patch.seq)) {
4926
+ throw new TypeError("Boundary patch seq must be a finite number.");
4927
+ }
4928
+
4929
+ if (patch.signals !== undefined && !isPlainObject(patch.signals)) {
4930
+ throw new TypeError("Boundary patch signals must be an object.");
4931
+ }
4932
+ if (patch.cache !== undefined && !isPlainObject(patch.cache)) {
4933
+ throw new TypeError("Boundary patch cache must be an object.");
4934
+ }
4935
+ if (patch.cache?.browser !== undefined && !isPlainObject(patch.cache.browser)) {
4936
+ throw new TypeError("Boundary patch cache.browser must be an object.");
4937
+ }
4938
+ if (patch.redirect !== undefined && (typeof patch.redirect !== "string" || patch.redirect.length === 0)) {
4939
+ throw new TypeError("Boundary patch redirect must be a non-empty string.");
4940
+ }
4941
+ if (patch.parentScope !== undefined && typeof patch.parentScope !== "string") {
4942
+ throw new TypeError("Boundary patch parentScope must be a string.");
4943
+ }
4944
+ if (patch.scope !== undefined && typeof patch.scope !== "string") {
4945
+ throw new TypeError("Boundary patch scope must be a string.");
4946
+ }
4947
+
4948
+ const hasHtml = Object.hasOwn(patch, "html") && patch.html != null;
4949
+ const hasSignals = patch.signals && Object.keys(patch.signals).length > 0;
4950
+ const hasBrowserCache = patch.cache?.browser && Object.keys(patch.cache.browser).length > 0;
4951
+ const hasRedirect = Boolean(patch.redirect);
4952
+ const hasError = Object.hasOwn(patch, "error");
4953
+ if (!hasHtml && !hasSignals && !hasBrowserCache && !hasRedirect && !hasError) {
4954
+ throw new TypeError("Boundary patch must include html, signals, cache.browser, redirect, or error.");
4955
+ }
4956
+
4957
+ return patch;
4958
+ }
4959
+
4960
+ function assertBoundary(boundary) {
4961
+ if (typeof boundary !== "string" || boundary.length === 0) {
4962
+ throw new TypeError("Boundary patch boundary must be a non-empty string.");
4963
+ }
4964
+ }
4965
+
4966
+ async function flushScheduler(scheduler, scope) {
4967
+ if (!scheduler) {
4968
+ return;
4969
+ }
4970
+ if (scope !== undefined && typeof scheduler.flushScope === "function") {
4971
+ await scheduler.flushScope(scope);
4972
+ return;
4973
+ }
4974
+ if (typeof scheduler.flush === "function") {
4975
+ await scheduler.flush();
4976
+ }
4977
+ }
4978
+
4979
+ async function followRedirect(redirect, router, loader) {
4980
+ if (router && typeof router.navigate === "function") {
4981
+ await router.navigate(redirect);
4982
+ return;
4983
+ }
4984
+ const location = loader?.root?.ownerDocument?.defaultView?.location ?? globalThis.location;
4985
+ location?.assign?.(redirect);
4986
+ }
4987
+
4988
+ function toStableError(value) {
4989
+ if (value instanceof Error) {
4990
+ return value;
4991
+ }
4992
+ if (value && typeof value === "object" && typeof value.message === "string") {
4993
+ return Object.assign(new Error(value.message), value);
4994
+ }
4995
+ return new Error(String(value));
4996
+ }
4997
+
4998
+ function toRecentEntry(result) {
4999
+ const entry = {
5000
+ boundary: result.boundary,
5001
+ seq: result.seq,
5002
+ status: result.status
5003
+ };
5004
+ if (result.status === "ignored-stale") {
5005
+ entry.lastSeq = result.lastSeq;
5006
+ }
5007
+ if (result.status === "ignored-destroyed" && result.parentScope !== undefined) {
5008
+ entry.parentScope = result.parentScope;
5009
+ }
5010
+ if (result.status === "redirected") {
5011
+ entry.redirect = result.redirect;
5012
+ }
5013
+ return entry;
5014
+ }
5015
+
5016
+ function isPlainObject(value) {
5017
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
5018
+ }
5019
+ return { createBoundaryReceiver };
5020
+ })();
5021
+
4712
5022
  const __delayModule = (() => {
4713
5023
  function delay(ms, signal) {
4714
5024
  if (signal?.aborted) {
@@ -4750,6 +5060,7 @@ const { defineApp: defineApp } = __appModule;
4750
5060
  const { readSnapshot: readSnapshot } = __appModule;
4751
5061
  const { attributeName: attributeName } = __attributesModule;
4752
5062
  const { defineAttributeConfig: defineAttributeConfig } = __attributesModule;
5063
+ const { createBoundaryReceiver: createBoundaryReceiver } = __boundaryReceiverModule;
4753
5064
  const { createCacheRegistry: createCacheRegistry } = __cacheModule;
4754
5065
  const { defineCache: defineCache } = __cacheModule;
4755
5066
  const { component: component } = __componentModule;
@@ -4777,4 +5088,4 @@ const { createSignalRegistry: createSignalRegistry } = __signalsModule;
4777
5088
  const { effect: effect } = __signalsModule;
4778
5089
  const { signal: signal } = __signalsModule;
4779
5090
 
4780
- export { asyncSignal, Async, createApp, defineApp, readSnapshot, attributeName, defineAttributeConfig, createCacheRegistry, defineCache, component, createComponentRegistry, defineComponent, delay, createHandlerRegistry, html, Loader, AsyncLoader, createPartialRegistry, createRegistryStore, createRouteRegistry, createRouter, defineRoute, route, createScheduler, applyServerResult, createServerProxy, resolveServerCommandArguments, unwrapServerResult, computed, createSignal, createSignalRegistry, effect, signal };
5091
+ export { asyncSignal, Async, createApp, defineApp, readSnapshot, attributeName, defineAttributeConfig, createBoundaryReceiver, createCacheRegistry, defineCache, component, createComponentRegistry, defineComponent, delay, createHandlerRegistry, html, Loader, AsyncLoader, createPartialRegistry, createRegistryStore, createRouteRegistry, createRouter, defineRoute, route, createScheduler, applyServerResult, createServerProxy, resolveServerCommandArguments, unwrapServerResult, computed, createSignal, createSignalRegistry, effect, signal };