@aglyn/tenant-feature-instance 1.0.0-beta.277 → 1.0.0-beta.283
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/package.json +9 -8
- package/src/lib/hooks/use-confirmed-doc.d.ts +73 -0
- package/src/lib/hooks/use-confirmed-doc.js +204 -0
- package/src/lib/hooks/use-confirmed-doc.js.map +1 -0
- package/src/lib/hooks/use-host-component-definitions.d.ts +40 -0
- package/src/lib/hooks/use-host-component-definitions.js +92 -0
- package/src/lib/hooks/use-host-component-definitions.js.map +1 -0
- package/src/lib/hooks/use-host-form-designs.d.ts +42 -0
- package/src/lib/hooks/use-host-form-designs.js +84 -0
- package/src/lib/hooks/use-host-form-designs.js.map +1 -0
- package/src/lib/hooks/use-org-plans.d.ts +47 -0
- package/src/lib/hooks/use-org-plans.js +164 -0
- package/src/lib/hooks/use-org-plans.js.map +1 -0
- package/src/lib/hooks/use-plugin-drawer-registration.d.ts +9 -0
- package/src/lib/hooks/use-plugin-drawer-registration.js +133 -0
- package/src/lib/hooks/use-plugin-drawer-registration.js.map +1 -0
- package/src/lib/hooks/use-site-marketplace-plugins.d.ts +35 -0
- package/src/lib/hooks/use-site-marketplace-plugins.js +99 -0
- package/src/lib/hooks/use-site-marketplace-plugins.js.map +1 -0
- package/src/lib/hooks/use-theme-library.d.ts +7 -0
- package/src/lib/hooks/use-theme-library.js +58 -0
- package/src/lib/hooks/use-theme-library.js.map +1 -0
- package/src/lib/hooks/use-user-name.js +8 -10
- package/src/lib/hooks/use-user-name.js.map +1 -1
- package/src/lib/hooks/use-user-photo.js +8 -2
- package/src/lib/hooks/use-user-photo.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-feature-instance",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.283",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,14 +25,15 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.283",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.283",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.283",
|
|
31
|
+
"@aglyn/shared-util-email": "1.0.0-beta.283",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.283",
|
|
33
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.283",
|
|
34
34
|
"@swc/helpers": "0.5.23",
|
|
35
|
-
"lodash-es": "^4.18.1"
|
|
35
|
+
"lodash-es": "^4.18.1",
|
|
36
|
+
"mobx": "^7.0.5"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"@mui/material": "^9.3.1",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase/firestore';
|
|
2
|
+
export interface ConfirmedDocState<T> {
|
|
3
|
+
/** The doc's data (with `$id`), or undefined while loading / when absent. */
|
|
4
|
+
data: T | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* True once an answer worth acting on has arrived: any snapshot that has
|
|
7
|
+
* data, or a MISS the server itself has confirmed. Consumers must not
|
|
8
|
+
* 404, fall back to a default, or otherwise treat "no doc" as real until
|
|
9
|
+
* this is true.
|
|
10
|
+
*/
|
|
11
|
+
ready: boolean;
|
|
12
|
+
/** True only when retries were exhausted — offer a retry, never a 404. */
|
|
13
|
+
error: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* `data` is being served from the local cache and the server has not
|
|
16
|
+
* confirmed it (AGL-1066). Never a reason to withhold the value — only a
|
|
17
|
+
* reason not to let it decide something irreversible.
|
|
18
|
+
*/
|
|
19
|
+
fromCache: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface UseConfirmedDocOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).
|
|
24
|
+
*
|
|
25
|
+
* The base contract trusts any snapshot that exists, whatever its source.
|
|
26
|
+
* That is right for ordinary content — the value was legitimately the
|
|
27
|
+
* user's a moment ago, and blanking it costs more than it buys. It is NOT
|
|
28
|
+
* right for the entitlement doc: `orgs/{orgId}` is the only plan source
|
|
29
|
+
* (AGL-238), and a cached copy from before a plan change renders the org
|
|
30
|
+
* at the wrong tier for as long as the session stays stale. AGL-887 closed
|
|
31
|
+
* the tombstone direction of exactly this fault (a Business workspace
|
|
32
|
+
* rendering as Free); this closes the stale-EXISTS direction.
|
|
33
|
+
*
|
|
34
|
+
* Opt-in because it can cost one extra server read, and only when the
|
|
35
|
+
* listener has actually failed to reach the server — see the deferral in
|
|
36
|
+
* the implementation. Enable it for entitlement-shaped documents only.
|
|
37
|
+
*/
|
|
38
|
+
confirmCachedHit?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Test seam — the in-flight map is module scope by design. */
|
|
41
|
+
export declare function resetConfirmedDocSharing(): void;
|
|
42
|
+
/**
|
|
43
|
+
* A doc listen that never presents an unconfirmed miss (AGL-928).
|
|
44
|
+
*
|
|
45
|
+
* This is the shared form of the contract four consumers learned the hard
|
|
46
|
+
* way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB
|
|
47
|
+
* persistence can serve a stale `noDocument` tombstone for a LIVE doc, and
|
|
48
|
+
* a resumed listen never re-sends the correction — so `exists() === false`
|
|
49
|
+
* from cache looks confirmed while being wrong. Symptoms ranged from valid
|
|
50
|
+
* hosts 404ing to a Business workspace rendering as Free.
|
|
51
|
+
*
|
|
52
|
+
* Contract implemented here:
|
|
53
|
+
* - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:
|
|
54
|
+
* right after sign-in (and on cold loads behind App Check) the first
|
|
55
|
+
* listen can sit in transient `permission-denied` before the ID token
|
|
56
|
+
* attaches (AGL-216); reactfire's cached Observable would replay that
|
|
57
|
+
* error forever.
|
|
58
|
+
* - A snapshot that EXISTS is trusted immediately, whatever its source.
|
|
59
|
+
* - A snapshot that is MISSING and came from cache is confirmed via
|
|
60
|
+
* `getDocFromServer` before it is believed; a failed confirm leaves
|
|
61
|
+
* `ready` false rather than lying.
|
|
62
|
+
* - Exhausted retries set `error` (and `ready`) so consumers can offer
|
|
63
|
+
* "try again" — never a false 404 or a fallback default.
|
|
64
|
+
*
|
|
65
|
+
* For QUERY listens the same contract lives in `useOrgHosts` (empty →
|
|
66
|
+
* `getDocsFromServer` confirm); extract it the same way if a third query
|
|
67
|
+
* consumer appears.
|
|
68
|
+
*
|
|
69
|
+
* @param segments Doc path segments (`['orgs', orgId]`), or null to idle
|
|
70
|
+
* (state resets while null, matching "clear on scope change").
|
|
71
|
+
*/
|
|
72
|
+
export declare function useConfirmedDoc<T extends Record<string, unknown>>(firestore: Firestore, segments: readonly [string, ...string[]] | null, options?: UseConfirmedDocOptions): ConfirmedDocState<T>;
|
|
73
|
+
export default useConfirmedDoc;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { doc, getDocFromServer, onSnapshot } from "firebase/firestore";
|
|
19
|
+
import { useEffect, useState } from "react";
|
|
20
|
+
const RETRY_DELAY_MS = 400;
|
|
21
|
+
const MAX_RETRIES = 8;
|
|
22
|
+
const MAX_RETRY_DELAY_MS = 15000;
|
|
23
|
+
/**
|
|
24
|
+
* How long a cached hit is allowed to stand before it is confirmed.
|
|
25
|
+
*
|
|
26
|
+
* Not zero, and this is the whole reason the option is affordable: on a
|
|
27
|
+
* healthy load the first snapshot is routinely from cache and the server
|
|
28
|
+
* snapshot follows within milliseconds. Confirming immediately would bill an
|
|
29
|
+
* extra read on every mount for every user; waiting means the confirm fires
|
|
30
|
+
* only when the server genuinely did not answer.
|
|
31
|
+
*/ const CONFIRM_CACHED_HIT_DELAY_MS = 1500;
|
|
32
|
+
/**
|
|
33
|
+
* Confirms in flight, keyed by document path (AGL-1440).
|
|
34
|
+
*
|
|
35
|
+
* `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts
|
|
36
|
+
* it seven-plus times per page — user menu, org switcher, branding, and three
|
|
37
|
+
* separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly
|
|
38
|
+
* free, because the SDK serves identical listens off ONE target; the confirms
|
|
39
|
+
* are not. Each instance scheduled its own `getDocFromServer`, so a console
|
|
40
|
+
* page load billed seven server reads of `orgs/{orgId}` to answer one question.
|
|
41
|
+
*
|
|
42
|
+
* Sharing the PROMISE, rather than suppressing the duplicates, is what keeps
|
|
43
|
+
* this behaviour-preserving: every instance still resolves from a real server
|
|
44
|
+
* snapshot and still calls `deliver(..., fromCache: false)`, so none of them is
|
|
45
|
+
* left displaying a value flagged unconfirmed. Suppressing instead would leave
|
|
46
|
+
* the six quiet instances on `fromCache: true` forever, which is a state
|
|
47
|
+
* consumers are told means "do not let this decide anything irreversible".
|
|
48
|
+
*
|
|
49
|
+
* The entry is dropped as soon as it settles, so this is a burst collapser and
|
|
50
|
+
* nothing more: a mount two seconds later asks the server again, exactly as it
|
|
51
|
+
* does today. NOTHING about the entitlement is cached — this shares one READ
|
|
52
|
+
* of the org document; every consumer evaluates the plan off it as before.
|
|
53
|
+
*/ const confirmsInFlight = new Map();
|
|
54
|
+
/** The server's copy of `ref`, shared with any confirm already in flight. */ function sharedConfirm(pathKey, ref) {
|
|
55
|
+
const existing = confirmsInFlight.get(pathKey);
|
|
56
|
+
if (existing) return existing;
|
|
57
|
+
const pending = getDocFromServer(ref).finally(()=>{
|
|
58
|
+
confirmsInFlight.delete(pathKey);
|
|
59
|
+
});
|
|
60
|
+
confirmsInFlight.set(pathKey, pending);
|
|
61
|
+
return pending;
|
|
62
|
+
}
|
|
63
|
+
/** Test seam — the in-flight map is module scope by design. */ export function resetConfirmedDocSharing() {
|
|
64
|
+
confirmsInFlight.clear();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A doc listen that never presents an unconfirmed miss (AGL-928).
|
|
68
|
+
*
|
|
69
|
+
* This is the shared form of the contract four consumers learned the hard
|
|
70
|
+
* way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB
|
|
71
|
+
* persistence can serve a stale `noDocument` tombstone for a LIVE doc, and
|
|
72
|
+
* a resumed listen never re-sends the correction — so `exists() === false`
|
|
73
|
+
* from cache looks confirmed while being wrong. Symptoms ranged from valid
|
|
74
|
+
* hosts 404ing to a Business workspace rendering as Free.
|
|
75
|
+
*
|
|
76
|
+
* Contract implemented here:
|
|
77
|
+
* - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:
|
|
78
|
+
* right after sign-in (and on cold loads behind App Check) the first
|
|
79
|
+
* listen can sit in transient `permission-denied` before the ID token
|
|
80
|
+
* attaches (AGL-216); reactfire's cached Observable would replay that
|
|
81
|
+
* error forever.
|
|
82
|
+
* - A snapshot that EXISTS is trusted immediately, whatever its source.
|
|
83
|
+
* - A snapshot that is MISSING and came from cache is confirmed via
|
|
84
|
+
* `getDocFromServer` before it is believed; a failed confirm leaves
|
|
85
|
+
* `ready` false rather than lying.
|
|
86
|
+
* - Exhausted retries set `error` (and `ready`) so consumers can offer
|
|
87
|
+
* "try again" — never a false 404 or a fallback default.
|
|
88
|
+
*
|
|
89
|
+
* For QUERY listens the same contract lives in `useOrgHosts` (empty →
|
|
90
|
+
* `getDocsFromServer` confirm); extract it the same way if a third query
|
|
91
|
+
* consumer appears.
|
|
92
|
+
*
|
|
93
|
+
* @param segments Doc path segments (`['orgs', orgId]`), or null to idle
|
|
94
|
+
* (state resets while null, matching "clear on scope change").
|
|
95
|
+
*/ export function useConfirmedDoc(firestore, segments, options = {}) {
|
|
96
|
+
const [state, setState] = useState({
|
|
97
|
+
data: undefined,
|
|
98
|
+
ready: false,
|
|
99
|
+
error: false,
|
|
100
|
+
fromCache: true
|
|
101
|
+
});
|
|
102
|
+
// Effect key: a new path must tear down and restart the listen.
|
|
103
|
+
const pathKey = segments ? segments.join('/') : null;
|
|
104
|
+
const confirmCachedHit = options.confirmCachedHit;
|
|
105
|
+
useEffect(()=>{
|
|
106
|
+
setState({
|
|
107
|
+
data: undefined,
|
|
108
|
+
ready: false,
|
|
109
|
+
error: false,
|
|
110
|
+
fromCache: true
|
|
111
|
+
});
|
|
112
|
+
if (!pathKey) return;
|
|
113
|
+
let cancelled = false;
|
|
114
|
+
let unsubscribe = null;
|
|
115
|
+
let timer = null;
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
// One confirm per mount, and never once the server has answered.
|
|
118
|
+
let confirmTimer = null;
|
|
119
|
+
let confirmRequested = false;
|
|
120
|
+
const ref = doc(firestore, pathKey);
|
|
121
|
+
const deliver = (exists, id, payload, fromCache)=>setState({
|
|
122
|
+
data: exists ? _extends({
|
|
123
|
+
$id: id
|
|
124
|
+
}, payload) : undefined,
|
|
125
|
+
ready: true,
|
|
126
|
+
error: false,
|
|
127
|
+
fromCache
|
|
128
|
+
});
|
|
129
|
+
const subscribe = ()=>{
|
|
130
|
+
unsubscribe = onSnapshot(ref, (snapshot)=>{
|
|
131
|
+
if (cancelled) return;
|
|
132
|
+
attempt = 0;
|
|
133
|
+
const fromCache = snapshot.metadata.fromCache;
|
|
134
|
+
if (!fromCache && confirmTimer) {
|
|
135
|
+
// The server answered on its own; nothing left to confirm.
|
|
136
|
+
clearTimeout(confirmTimer);
|
|
137
|
+
confirmTimer = null;
|
|
138
|
+
}
|
|
139
|
+
if (!snapshot.exists() && fromCache) {
|
|
140
|
+
// Unconfirmed miss: ask the server before believing it. On
|
|
141
|
+
// failure stay un-ready — the listener remains subscribed and
|
|
142
|
+
// the next (server) snapshot resolves it.
|
|
143
|
+
void sharedConfirm(pathKey, ref).then((server)=>{
|
|
144
|
+
if (cancelled) return;
|
|
145
|
+
deliver(server.exists(), server.id, server.data(), false);
|
|
146
|
+
}).catch(()=>undefined);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache);
|
|
150
|
+
/**
|
|
151
|
+
* Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.
|
|
152
|
+
*
|
|
153
|
+
* The value is already delivered above and stays on screen
|
|
154
|
+
* whatever happens here — this never blanks the page and never
|
|
155
|
+
* holds `ready` false, because a transient blip must not become a
|
|
156
|
+
* wrong tier OR a 404. It only replaces a cached answer with the
|
|
157
|
+
* server's when the server can be reached.
|
|
158
|
+
*/ if (confirmCachedHit && fromCache && snapshot.exists() && !confirmRequested && !confirmTimer) {
|
|
159
|
+
confirmTimer = setTimeout(()=>{
|
|
160
|
+
confirmTimer = null;
|
|
161
|
+
confirmRequested = true;
|
|
162
|
+
void sharedConfirm(pathKey, ref).then((server)=>{
|
|
163
|
+
if (cancelled) return;
|
|
164
|
+
deliver(server.exists(), server.id, server.data(), false);
|
|
165
|
+
})// Could not reach the server: keep showing the cached value,
|
|
166
|
+
// still flagged `fromCache`. Refusing to answer would be the
|
|
167
|
+
// stale-`ready` false-404 this hook exists to prevent.
|
|
168
|
+
.catch(()=>undefined);
|
|
169
|
+
}, CONFIRM_CACHED_HIT_DELAY_MS);
|
|
170
|
+
}
|
|
171
|
+
}, ()=>{
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
unsubscribe == null ? void 0 : unsubscribe();
|
|
174
|
+
if (attempt < MAX_RETRIES) {
|
|
175
|
+
const delay = Math.min(RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
|
|
176
|
+
attempt += 1;
|
|
177
|
+
timer = setTimeout(subscribe, delay);
|
|
178
|
+
} else {
|
|
179
|
+
setState({
|
|
180
|
+
data: undefined,
|
|
181
|
+
ready: true,
|
|
182
|
+
error: true,
|
|
183
|
+
fromCache: true
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
subscribe();
|
|
189
|
+
return ()=>{
|
|
190
|
+
cancelled = true;
|
|
191
|
+
if (timer) clearTimeout(timer);
|
|
192
|
+
if (confirmTimer) clearTimeout(confirmTimer);
|
|
193
|
+
unsubscribe == null ? void 0 : unsubscribe();
|
|
194
|
+
};
|
|
195
|
+
}, [
|
|
196
|
+
firestore,
|
|
197
|
+
pathKey,
|
|
198
|
+
confirmCachedHit
|
|
199
|
+
]);
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
export default useConfirmedDoc;
|
|
203
|
+
|
|
204
|
+
//# sourceMappingURL=use-confirmed-doc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-confirmed-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n doc,\n getDocFromServer,\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type Firestore,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 8\nconst MAX_RETRY_DELAY_MS = 15_000\n\nexport interface ConfirmedDocState<T> {\n /** The doc's data (with `$id`), or undefined while loading / when absent. */\n data: T | undefined\n /**\n * True once an answer worth acting on has arrived: any snapshot that has\n * data, or a MISS the server itself has confirmed. Consumers must not\n * 404, fall back to a default, or otherwise treat \"no doc\" as real until\n * this is true.\n */\n ready: boolean\n /** True only when retries were exhausted — offer a retry, never a 404. */\n error: boolean\n /**\n * `data` is being served from the local cache and the server has not\n * confirmed it (AGL-1066). Never a reason to withhold the value — only a\n * reason not to let it decide something irreversible.\n */\n fromCache: boolean\n}\n\nexport interface UseConfirmedDocOptions {\n /**\n * Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).\n *\n * The base contract trusts any snapshot that exists, whatever its source.\n * That is right for ordinary content — the value was legitimately the\n * user's a moment ago, and blanking it costs more than it buys. It is NOT\n * right for the entitlement doc: `orgs/{orgId}` is the only plan source\n * (AGL-238), and a cached copy from before a plan change renders the org\n * at the wrong tier for as long as the session stays stale. AGL-887 closed\n * the tombstone direction of exactly this fault (a Business workspace\n * rendering as Free); this closes the stale-EXISTS direction.\n *\n * Opt-in because it can cost one extra server read, and only when the\n * listener has actually failed to reach the server — see the deferral in\n * the implementation. Enable it for entitlement-shaped documents only.\n */\n confirmCachedHit?: boolean\n}\n\n/**\n * How long a cached hit is allowed to stand before it is confirmed.\n *\n * Not zero, and this is the whole reason the option is affordable: on a\n * healthy load the first snapshot is routinely from cache and the server\n * snapshot follows within milliseconds. Confirming immediately would bill an\n * extra read on every mount for every user; waiting means the confirm fires\n * only when the server genuinely did not answer.\n */\nconst CONFIRM_CACHED_HIT_DELAY_MS = 1_500\n\n/**\n * Confirms in flight, keyed by document path (AGL-1440).\n *\n * `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts\n * it seven-plus times per page — user menu, org switcher, branding, and three\n * separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly\n * free, because the SDK serves identical listens off ONE target; the confirms\n * are not. Each instance scheduled its own `getDocFromServer`, so a console\n * page load billed seven server reads of `orgs/{orgId}` to answer one question.\n *\n * Sharing the PROMISE, rather than suppressing the duplicates, is what keeps\n * this behaviour-preserving: every instance still resolves from a real server\n * snapshot and still calls `deliver(..., fromCache: false)`, so none of them is\n * left displaying a value flagged unconfirmed. Suppressing instead would leave\n * the six quiet instances on `fromCache: true` forever, which is a state\n * consumers are told means \"do not let this decide anything irreversible\".\n *\n * The entry is dropped as soon as it settles, so this is a burst collapser and\n * nothing more: a mount two seconds later asks the server again, exactly as it\n * does today. NOTHING about the entitlement is cached — this shares one READ\n * of the org document; every consumer evaluates the plan off it as before.\n */\nconst confirmsInFlight = new Map<\n string,\n Promise<DocumentSnapshot<DocumentData>>\n>()\n\n/** The server's copy of `ref`, shared with any confirm already in flight. */\nfunction sharedConfirm(\n pathKey: string,\n ref: DocumentReference,\n): Promise<DocumentSnapshot<DocumentData>> {\n const existing = confirmsInFlight.get(pathKey)\n if (existing) return existing\n const pending = getDocFromServer(ref).finally(() => {\n confirmsInFlight.delete(pathKey)\n })\n confirmsInFlight.set(pathKey, pending)\n return pending\n}\n\n/** Test seam — the in-flight map is module scope by design. */\nexport function resetConfirmedDocSharing(): void {\n confirmsInFlight.clear()\n}\n\n/**\n * A doc listen that never presents an unconfirmed miss (AGL-928).\n *\n * This is the shared form of the contract four consumers learned the hard\n * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB\n * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and\n * a resumed listen never re-sends the correction — so `exists() === false`\n * from cache looks confirmed while being wrong. Symptoms ranged from valid\n * hosts 404ing to a Business workspace rendering as Free.\n *\n * Contract implemented here:\n * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:\n * right after sign-in (and on cold loads behind App Check) the first\n * listen can sit in transient `permission-denied` before the ID token\n * attaches (AGL-216); reactfire's cached Observable would replay that\n * error forever.\n * - A snapshot that EXISTS is trusted immediately, whatever its source.\n * - A snapshot that is MISSING and came from cache is confirmed via\n * `getDocFromServer` before it is believed; a failed confirm leaves\n * `ready` false rather than lying.\n * - Exhausted retries set `error` (and `ready`) so consumers can offer\n * \"try again\" — never a false 404 or a fallback default.\n *\n * For QUERY listens the same contract lives in `useOrgHosts` (empty →\n * `getDocsFromServer` confirm); extract it the same way if a third query\n * consumer appears.\n *\n * @param segments Doc path segments (`['orgs', orgId]`), or null to idle\n * (state resets while null, matching \"clear on scope change\").\n */\nexport function useConfirmedDoc<T extends Record<string, unknown>>(\n firestore: Firestore,\n segments: readonly [string, ...string[]] | null,\n options: UseConfirmedDocOptions = {},\n): ConfirmedDocState<T> {\n const [state, setState] = useState<ConfirmedDocState<T>>({\n data: undefined,\n ready: false,\n error: false,\n fromCache: true,\n })\n // Effect key: a new path must tear down and restart the listen.\n const pathKey = segments ? segments.join('/') : null\n const confirmCachedHit = options.confirmCachedHit\n\n useEffect(() => {\n setState({ data: undefined, ready: false, error: false, fromCache: true })\n if (!pathKey) return\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n let attempt = 0\n // One confirm per mount, and never once the server has answered.\n let confirmTimer: ReturnType<typeof setTimeout> | null = null\n let confirmRequested = false\n const ref = doc(firestore, pathKey)\n\n const deliver = (\n exists: boolean,\n id: string,\n payload: Record<string, unknown> | undefined,\n fromCache: boolean,\n ) =>\n setState({\n data: exists ? ({ $id: id, ...payload } as unknown as T) : undefined,\n ready: true,\n error: false,\n fromCache,\n })\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n if (cancelled) return\n attempt = 0\n const fromCache = snapshot.metadata.fromCache\n if (!fromCache && confirmTimer) {\n // The server answered on its own; nothing left to confirm.\n clearTimeout(confirmTimer)\n confirmTimer = null\n }\n if (!snapshot.exists() && fromCache) {\n // Unconfirmed miss: ask the server before believing it. On\n // failure stay un-ready — the listener remains subscribed and\n // the next (server) snapshot resolves it.\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n .catch(() => undefined)\n return\n }\n deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache)\n\n /**\n * Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.\n *\n * The value is already delivered above and stays on screen\n * whatever happens here — this never blanks the page and never\n * holds `ready` false, because a transient blip must not become a\n * wrong tier OR a 404. It only replaces a cached answer with the\n * server's when the server can be reached.\n */\n if (\n confirmCachedHit &&\n fromCache &&\n snapshot.exists() &&\n !confirmRequested &&\n !confirmTimer\n ) {\n confirmTimer = setTimeout(() => {\n confirmTimer = null\n confirmRequested = true\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n // Could not reach the server: keep showing the cached value,\n // still flagged `fromCache`. Refusing to answer would be the\n // stale-`ready` false-404 this hook exists to prevent.\n .catch(() => undefined)\n }, CONFIRM_CACHED_HIT_DELAY_MS)\n }\n },\n () => {\n if (cancelled) return\n unsubscribe?.()\n if (attempt < MAX_RETRIES) {\n const delay = Math.min(\n RETRY_DELAY_MS * 2 ** attempt,\n MAX_RETRY_DELAY_MS,\n )\n attempt += 1\n timer = setTimeout(subscribe, delay)\n } else {\n setState({\n data: undefined,\n ready: true,\n error: true,\n fromCache: true,\n })\n }\n },\n )\n }\n subscribe()\n\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n if (confirmTimer) clearTimeout(confirmTimer)\n unsubscribe?.()\n }\n }, [firestore, pathKey, confirmCachedHit])\n\n return state\n}\n\nexport default useConfirmedDoc\n"],"names":["doc","getDocFromServer","onSnapshot","useEffect","useState","RETRY_DELAY_MS","MAX_RETRIES","MAX_RETRY_DELAY_MS","CONFIRM_CACHED_HIT_DELAY_MS","confirmsInFlight","Map","sharedConfirm","pathKey","ref","existing","get","pending","finally","delete","set","resetConfirmedDocSharing","clear","useConfirmedDoc","firestore","segments","options","state","setState","data","undefined","ready","error","fromCache","join","confirmCachedHit","cancelled","unsubscribe","timer","attempt","confirmTimer","confirmRequested","deliver","exists","id","payload","$id","subscribe","snapshot","metadata","clearTimeout","then","server","catch","setTimeout","delay","Math","min"],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type * as Aglyn from '@aglyn/aglyn';
|
|
2
|
+
export interface UseHostComponentDefinitionsResult {
|
|
3
|
+
/** Raw definition docs, `$id` included, tombstones and all. */
|
|
4
|
+
docs: Aglyn.AglynHostComponent[];
|
|
5
|
+
/**
|
|
6
|
+
* Live definitions keyed by id in the shape
|
|
7
|
+
* `composeReusableComponentNodes` consumes, or `undefined` until the
|
|
8
|
+
* first snapshot settles — see the callers for why "loading" and "this
|
|
9
|
+
* host has none" must not look the same.
|
|
10
|
+
*/
|
|
11
|
+
definitions: Record<string, Aglyn.ReusableComponentTree> | undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the
|
|
15
|
+
* host's reusable-component definitions, read through ONE query and shared
|
|
16
|
+
* by everything in the editor that needs them (AGL-1217).
|
|
17
|
+
*
|
|
18
|
+
* "One query", not "read once": `useFirestoreCollection` is a live
|
|
19
|
+
* `onSnapshot` listener, and that is load-bearing rather than incidental.
|
|
20
|
+
* It is the entire transport for propagating a component edit into already
|
|
21
|
+
* open besigners (AGL-1898) — publish a component in one tab and every
|
|
22
|
+
* canvas rendering an instance of it re-renders, because this map is a new
|
|
23
|
+
* object and the consumers memo on it. Swapping this for a one-shot read to
|
|
24
|
+
* "save reads" would silently take that away, and nothing would fail: the
|
|
25
|
+
* canvas would simply keep drawing a stale component until reloaded.
|
|
26
|
+
*
|
|
27
|
+
* Note the boundary. This watches the component's PARENT doc, which only
|
|
28
|
+
* Publish writes; the component editor's Save writes the version doc and is
|
|
29
|
+
* invisible here.
|
|
30
|
+
*
|
|
31
|
+
* One hook rather than a read per consumer so the console asks the same
|
|
32
|
+
* question the same way — identical `Query`, so the SDK serves every caller
|
|
33
|
+
* from a single listener — and so the skip rules stay in one place. A doc
|
|
34
|
+
* missing `rootId`/`nodes` is unpublished, not broken; skipping it here is
|
|
35
|
+
* what keeps the canvas agreeing with the live site about which instances
|
|
36
|
+
* expand. Fail-open like the server loader: an error yields an empty map,
|
|
37
|
+
* leaving instances as-is rather than blanking the document.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useHostComponentDefinitions(hostId: string | undefined): UseHostComponentDefinitionsResult;
|
|
40
|
+
export default useHostComponentDefinitions;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
// Deep path, keeping the type-only barrel import above erased.
|
|
19
|
+
import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
|
|
20
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
21
|
+
import { collection, limit, query } from "firebase/firestore";
|
|
22
|
+
import { useMemo } from "react";
|
|
23
|
+
import useFirestoreCollection from "./use-firestore-collection.js";
|
|
24
|
+
/**
|
|
25
|
+
* Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the
|
|
26
|
+
* host's reusable-component definitions, read through ONE query and shared
|
|
27
|
+
* by everything in the editor that needs them (AGL-1217).
|
|
28
|
+
*
|
|
29
|
+
* "One query", not "read once": `useFirestoreCollection` is a live
|
|
30
|
+
* `onSnapshot` listener, and that is load-bearing rather than incidental.
|
|
31
|
+
* It is the entire transport for propagating a component edit into already
|
|
32
|
+
* open besigners (AGL-1898) — publish a component in one tab and every
|
|
33
|
+
* canvas rendering an instance of it re-renders, because this map is a new
|
|
34
|
+
* object and the consumers memo on it. Swapping this for a one-shot read to
|
|
35
|
+
* "save reads" would silently take that away, and nothing would fail: the
|
|
36
|
+
* canvas would simply keep drawing a stale component until reloaded.
|
|
37
|
+
*
|
|
38
|
+
* Note the boundary. This watches the component's PARENT doc, which only
|
|
39
|
+
* Publish writes; the component editor's Save writes the version doc and is
|
|
40
|
+
* invisible here.
|
|
41
|
+
*
|
|
42
|
+
* One hook rather than a read per consumer so the console asks the same
|
|
43
|
+
* question the same way — identical `Query`, so the SDK serves every caller
|
|
44
|
+
* from a single listener — and so the skip rules stay in one place. A doc
|
|
45
|
+
* missing `rootId`/`nodes` is unpublished, not broken; skipping it here is
|
|
46
|
+
* what keeps the canvas agreeing with the live site about which instances
|
|
47
|
+
* expand. Fail-open like the server loader: an error yields an empty map,
|
|
48
|
+
* leaving instances as-is rather than blanking the document.
|
|
49
|
+
*/ export function useHostComponentDefinitions(hostId) {
|
|
50
|
+
const firestore = useFirestore();
|
|
51
|
+
const { data, status } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'components'), limit(200)) : null, [
|
|
52
|
+
firestore,
|
|
53
|
+
hostId
|
|
54
|
+
], {
|
|
55
|
+
idField: '$id'
|
|
56
|
+
});
|
|
57
|
+
const definitions = useMemo(()=>{
|
|
58
|
+
if (status === 'loading') return undefined;
|
|
59
|
+
const next = {};
|
|
60
|
+
for (const value of data != null ? data : []){
|
|
61
|
+
var _value_props, _value_icon;
|
|
62
|
+
if ((value == null ? void 0 : value.deletedAt) || !(value == null ? void 0 : value.nodes) || !(value == null ? void 0 : value.rootId)) continue;
|
|
63
|
+
// BOTH stored forms (AGL-1151). This is a raw collection query, so
|
|
64
|
+
// `nodes` arrives exactly as stored — msgpack for anything promoted
|
|
65
|
+
// since components were compressed, a plain map for everything older.
|
|
66
|
+
// The canvas grafts every instance from this map, so a `Bytes` reaching
|
|
67
|
+
// it blanks every placed component in the editor while the live site
|
|
68
|
+
// still renders them.
|
|
69
|
+
const nodes = decodeStoredNodes(value.nodes);
|
|
70
|
+
if (!nodes) continue;
|
|
71
|
+
next[value.$id] = _extends({
|
|
72
|
+
rootId: value.rootId,
|
|
73
|
+
nodes
|
|
74
|
+
}, ((_value_props = value.props) == null ? void 0 : _value_props.length) && {
|
|
75
|
+
props: value.props
|
|
76
|
+
}, ((_value_icon = value.icon) == null ? void 0 : _value_icon.iconPath) && {
|
|
77
|
+
icon: value.icon
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return next;
|
|
81
|
+
}, [
|
|
82
|
+
data,
|
|
83
|
+
status
|
|
84
|
+
]);
|
|
85
|
+
return {
|
|
86
|
+
docs: data != null ? data : [],
|
|
87
|
+
definitions
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export default useHostComponentDefinitions;
|
|
91
|
+
|
|
92
|
+
//# sourceMappingURL=use-host-component-definitions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-component-definitions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type * as Aglyn from '@aglyn/aglyn'\n// Deep path, keeping the type-only barrel import above erased.\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { useFirestore } from './firebase/firebase-services'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport useFirestoreCollection from './use-firestore-collection'\n\nexport interface UseHostComponentDefinitionsResult {\n /** Raw definition docs, `$id` included, tombstones and all. */\n docs: Aglyn.AglynHostComponent[]\n /**\n * Live definitions keyed by id in the shape\n * `composeReusableComponentNodes` consumes, or `undefined` until the\n * first snapshot settles — see the callers for why \"loading\" and \"this\n * host has none\" must not look the same.\n */\n definitions: Record<string, Aglyn.ReusableComponentTree> | undefined\n}\n\n/**\n * Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the\n * host's reusable-component definitions, read through ONE query and shared\n * by everything in the editor that needs them (AGL-1217).\n *\n * \"One query\", not \"read once\": `useFirestoreCollection` is a live\n * `onSnapshot` listener, and that is load-bearing rather than incidental.\n * It is the entire transport for propagating a component edit into already\n * open besigners (AGL-1898) — publish a component in one tab and every\n * canvas rendering an instance of it re-renders, because this map is a new\n * object and the consumers memo on it. Swapping this for a one-shot read to\n * \"save reads\" would silently take that away, and nothing would fail: the\n * canvas would simply keep drawing a stale component until reloaded.\n *\n * Note the boundary. This watches the component's PARENT doc, which only\n * Publish writes; the component editor's Save writes the version doc and is\n * invisible here.\n *\n * One hook rather than a read per consumer so the console asks the same\n * question the same way — identical `Query`, so the SDK serves every caller\n * from a single listener — and so the skip rules stay in one place. A doc\n * missing `rootId`/`nodes` is unpublished, not broken; skipping it here is\n * what keeps the canvas agreeing with the live site about which instances\n * expand. Fail-open like the server loader: an error yields an empty map,\n * leaving instances as-is rather than blanking the document.\n */\nexport function useHostComponentDefinitions(\n hostId: string | undefined,\n): UseHostComponentDefinitionsResult {\n const firestore = useFirestore()\n const { data, status } = useFirestoreCollection<Aglyn.AglynHostComponent>(\n () =>\n hostId\n ? query(collection(firestore, 'hosts', hostId, 'components'), limit(200))\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const definitions = useMemo(() => {\n if (status === 'loading') return undefined\n const next: Record<string, Aglyn.ReusableComponentTree> = {}\n for (const value of data ?? []) {\n if (value?.deletedAt || !value?.nodes || !value?.rootId) continue\n // BOTH stored forms (AGL-1151). This is a raw collection query, so\n // `nodes` arrives exactly as stored — msgpack for anything promoted\n // since components were compressed, a plain map for everything older.\n // The canvas grafts every instance from this map, so a `Bytes` reaching\n // it blanks every placed component in the editor while the live site\n // still renders them.\n const nodes = decodeStoredNodes<Aglyn.ReusableComponentTree['nodes']>(\n value.nodes,\n )\n if (!nodes) continue\n next[value.$id] = {\n rootId: value.rootId,\n nodes,\n // Declared props ride along (AGL-1247) so the editor's graft\n // substitutes the same values the live site will.\n ...(value.props?.length && { props: value.props }),\n // As does the chosen icon (AGL-1193) — the hierarchy row and canvas\n // badge draw it from here, so an instance's icon needs no read of\n // its own.\n ...(value.icon?.iconPath && { icon: value.icon }),\n }\n }\n return next\n }, [data, status])\n\n return { docs: data ?? [], definitions }\n}\n\nexport default useHostComponentDefinitions\n"],"names":["decodeStoredNodes","useFirestore","collection","limit","query","useMemo","useFirestoreCollection","useHostComponentDefinitions","hostId","firestore","data","status","idField","definitions","undefined","next","value","deletedAt","nodes","rootId","$id","props","length","icon","iconPath","docs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,+DAA+D;AAC/D,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,OAAOC,4BAA4B,gCAA4B;AAc/D;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,4BACdC,MAA0B;IAE1B,MAAMC,YAAYR;IAClB,MAAM,EAAES,IAAI,EAAEC,MAAM,EAAE,GAAGL,uBACvB,IACEE,SACIJ,MAAMF,WAAWO,WAAW,SAASD,QAAQ,eAAeL,MAAM,QAClE,MACN;QAACM;QAAWD;KAAO,EACnB;QAAEI,SAAS;IAAM;IAGnB,MAAMC,cAAcR,QAAQ;QAC1B,IAAIM,WAAW,WAAW,OAAOG;QACjC,MAAMC,OAAoD,CAAC;QAC3D,KAAK,MAAMC,SAASN,eAAAA,OAAQ,EAAE,CAAE;gBAiBxBM,cAIAA;YApBN,IAAIA,CAAAA,yBAAAA,MAAOC,SAAS,KAAI,EAACD,yBAAAA,MAAOE,KAAK,KAAI,EAACF,yBAAAA,MAAOG,MAAM,GAAE;YACzD,mEAAmE;YACnE,oEAAoE;YACpE,sEAAsE;YACtE,wEAAwE;YACxE,qEAAqE;YACrE,sBAAsB;YACtB,MAAMD,QAAQlB,kBACZgB,MAAME,KAAK;YAEb,IAAI,CAACA,OAAO;YACZH,IAAI,CAACC,MAAMI,GAAG,CAAC,GAAG;gBAChBD,QAAQH,MAAMG,MAAM;gBACpBD;eAGIF,EAAAA,eAAAA,MAAMK,KAAK,qBAAXL,aAAaM,MAAM,KAAI;gBAAED,OAAOL,MAAMK,KAAK;YAAC,GAI5CL,EAAAA,cAAAA,MAAMO,IAAI,qBAAVP,YAAYQ,QAAQ,KAAI;gBAAED,MAAMP,MAAMO,IAAI;YAAC;QAEnD;QACA,OAAOR;IACT,GAAG;QAACL;QAAMC;KAAO;IAEjB,OAAO;QAAEc,IAAI,EAAEf,eAAAA,OAAQ,EAAE;QAAEG;IAAY;AACzC;AAEA,eAAeN,4BAA2B"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type * as Aglyn from '@aglyn/aglyn';
|
|
2
|
+
/** A form document as the query hands it back, with its id stamped on. */
|
|
3
|
+
type FormDocumentWithId = Aglyn.FormDocument & {
|
|
4
|
+
$id: string;
|
|
5
|
+
};
|
|
6
|
+
export interface UseHostFormDesignsResult {
|
|
7
|
+
/** Raw form docs, `$id` included, unpublished ones and all. */
|
|
8
|
+
docs: FormDocumentWithId[];
|
|
9
|
+
/**
|
|
10
|
+
* The published design of each form, keyed by id, or `undefined` until the
|
|
11
|
+
* first snapshot settles — "loading" and "this host has none" must not look
|
|
12
|
+
* the same, or the canvas paints the page's own fields and swaps them for
|
|
13
|
+
* the entity's a beat later.
|
|
14
|
+
*/
|
|
15
|
+
designs: Record<string, Aglyn.PlacedFormDesign> | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's
|
|
19
|
+
* form entities read through ONE query, so a placed form draws its entity's
|
|
20
|
+
* fields on the canvas exactly as the published page will
|
|
21
|
+
* (`docs/specs/reusable-forms.md`).
|
|
22
|
+
*
|
|
23
|
+
* A sibling of `use-host-component-definitions.ts` in every respect that
|
|
24
|
+
* matters, because the two answer the same question about different documents.
|
|
25
|
+
* That includes the live `onSnapshot`, which is load-bearing rather than
|
|
26
|
+
* incidental: it is the whole transport for propagating a form publish into
|
|
27
|
+
* already-open screen besigners. A one-shot read would "save reads" and
|
|
28
|
+
* silently leave every open canvas drawing a stale form until reloaded.
|
|
29
|
+
*
|
|
30
|
+
* Note the boundary, the same one the components hook draws: this watches the
|
|
31
|
+
* form's PARENT doc, which only Publish writes. The form besigner's Save
|
|
32
|
+
* writes the version doc and is invisible here — a screen must not show fields
|
|
33
|
+
* the live site has never had.
|
|
34
|
+
*
|
|
35
|
+
* A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,
|
|
36
|
+
* is unpublished rather than broken; skipping it is what keeps the canvas
|
|
37
|
+
* agreeing with the live site about which placements resolve. Fail-open like
|
|
38
|
+
* the server loader: an error yields an empty map, leaving each placement
|
|
39
|
+
* rendering its own fields rather than blanking it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useHostFormDesigns(hostId: string | undefined): UseHostFormDesignsResult;
|
|
42
|
+
export default useHostFormDesigns;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
// Deep path, keeping the type-only barrel import above erased.
|
|
18
|
+
import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
|
|
19
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
20
|
+
import { collection, limit, query } from "firebase/firestore";
|
|
21
|
+
import { useMemo } from "react";
|
|
22
|
+
import useFirestoreCollection from "./use-firestore-collection.js";
|
|
23
|
+
/** Matches the tenant render read, so the canvas cannot see more than a page. */ const FORMS_PER_CANVAS = 200;
|
|
24
|
+
/**
|
|
25
|
+
* Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's
|
|
26
|
+
* form entities read through ONE query, so a placed form draws its entity's
|
|
27
|
+
* fields on the canvas exactly as the published page will
|
|
28
|
+
* (`docs/specs/reusable-forms.md`).
|
|
29
|
+
*
|
|
30
|
+
* A sibling of `use-host-component-definitions.ts` in every respect that
|
|
31
|
+
* matters, because the two answer the same question about different documents.
|
|
32
|
+
* That includes the live `onSnapshot`, which is load-bearing rather than
|
|
33
|
+
* incidental: it is the whole transport for propagating a form publish into
|
|
34
|
+
* already-open screen besigners. A one-shot read would "save reads" and
|
|
35
|
+
* silently leave every open canvas drawing a stale form until reloaded.
|
|
36
|
+
*
|
|
37
|
+
* Note the boundary, the same one the components hook draws: this watches the
|
|
38
|
+
* form's PARENT doc, which only Publish writes. The form besigner's Save
|
|
39
|
+
* writes the version doc and is invisible here — a screen must not show fields
|
|
40
|
+
* the live site has never had.
|
|
41
|
+
*
|
|
42
|
+
* A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,
|
|
43
|
+
* is unpublished rather than broken; skipping it is what keeps the canvas
|
|
44
|
+
* agreeing with the live site about which placements resolve. Fail-open like
|
|
45
|
+
* the server loader: an error yields an empty map, leaving each placement
|
|
46
|
+
* rendering its own fields rather than blanking it.
|
|
47
|
+
*/ export function useHostFormDesigns(hostId) {
|
|
48
|
+
const firestore = useFirestore();
|
|
49
|
+
const { data, status } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'forms'), limit(FORMS_PER_CANVAS)) : null, [
|
|
50
|
+
firestore,
|
|
51
|
+
hostId
|
|
52
|
+
], {
|
|
53
|
+
idField: '$id'
|
|
54
|
+
});
|
|
55
|
+
const designs = useMemo(()=>{
|
|
56
|
+
if (status === 'loading') return undefined;
|
|
57
|
+
const next = {};
|
|
58
|
+
for (const value of data != null ? data : []){
|
|
59
|
+
if (!(value == null ? void 0 : value.nodes) || !(value == null ? void 0 : value.rootId)) continue;
|
|
60
|
+
// BOTH stored forms (AGL-1151). This is a raw collection query, so
|
|
61
|
+
// `nodes` arrives exactly as stored — msgpack for anything published
|
|
62
|
+
// since compression landed, a plain map for anything older. A `Bytes`
|
|
63
|
+
// reaching the graft has no `rootId` entry, so every placed form on the
|
|
64
|
+
// canvas would draw empty while the live site still renders it.
|
|
65
|
+
const nodes = decodeStoredNodes(value.nodes);
|
|
66
|
+
if (!(nodes == null ? void 0 : nodes[value.rootId])) continue;
|
|
67
|
+
next[value.$id] = {
|
|
68
|
+
rootId: value.rootId,
|
|
69
|
+
nodes
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return next;
|
|
73
|
+
}, [
|
|
74
|
+
data,
|
|
75
|
+
status
|
|
76
|
+
]);
|
|
77
|
+
return {
|
|
78
|
+
docs: data != null ? data : [],
|
|
79
|
+
designs
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export default useHostFormDesigns;
|
|
83
|
+
|
|
84
|
+
//# sourceMappingURL=use-host-form-designs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-form-designs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type * as Aglyn from '@aglyn/aglyn'\n// Deep path, keeping the type-only barrel import above erased.\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { useFirestore } from './firebase/firebase-services'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport useFirestoreCollection from './use-firestore-collection'\n\n/** Matches the tenant render read, so the canvas cannot see more than a page. */\nconst FORMS_PER_CANVAS = 200\n\n/** A form document as the query hands it back, with its id stamped on. */\ntype FormDocumentWithId = Aglyn.FormDocument & { $id: string }\n\nexport interface UseHostFormDesignsResult {\n /** Raw form docs, `$id` included, unpublished ones and all. */\n docs: FormDocumentWithId[]\n /**\n * The published design of each form, keyed by id, or `undefined` until the\n * first snapshot settles — \"loading\" and \"this host has none\" must not look\n * the same, or the canvas paints the page's own fields and swaps them for\n * the entity's a beat later.\n */\n designs: Record<string, Aglyn.PlacedFormDesign> | undefined\n}\n\n/**\n * Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's\n * form entities read through ONE query, so a placed form draws its entity's\n * fields on the canvas exactly as the published page will\n * (`docs/specs/reusable-forms.md`).\n *\n * A sibling of `use-host-component-definitions.ts` in every respect that\n * matters, because the two answer the same question about different documents.\n * That includes the live `onSnapshot`, which is load-bearing rather than\n * incidental: it is the whole transport for propagating a form publish into\n * already-open screen besigners. A one-shot read would \"save reads\" and\n * silently leave every open canvas drawing a stale form until reloaded.\n *\n * Note the boundary, the same one the components hook draws: this watches the\n * form's PARENT doc, which only Publish writes. The form besigner's Save\n * writes the version doc and is invisible here — a screen must not show fields\n * the live site has never had.\n *\n * A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,\n * is unpublished rather than broken; skipping it is what keeps the canvas\n * agreeing with the live site about which placements resolve. Fail-open like\n * the server loader: an error yields an empty map, leaving each placement\n * rendering its own fields rather than blanking it.\n */\nexport function useHostFormDesigns(\n hostId: string | undefined,\n): UseHostFormDesignsResult {\n const firestore = useFirestore()\n const { data, status } = useFirestoreCollection<FormDocumentWithId>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'forms'),\n limit(FORMS_PER_CANVAS),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const designs = useMemo(() => {\n if (status === 'loading') return undefined\n const next: Record<string, Aglyn.PlacedFormDesign> = {}\n for (const value of data ?? []) {\n if (!value?.nodes || !value?.rootId) continue\n // BOTH stored forms (AGL-1151). This is a raw collection query, so\n // `nodes` arrives exactly as stored — msgpack for anything published\n // since compression landed, a plain map for anything older. A `Bytes`\n // reaching the graft has no `rootId` entry, so every placed form on the\n // canvas would draw empty while the live site still renders it.\n const nodes = decodeStoredNodes<\n NonNullable<Aglyn.FormDocument['nodes']>\n >(value.nodes)\n if (!nodes?.[value.rootId]) continue\n next[value.$id] = { rootId: value.rootId, nodes }\n }\n return next\n }, [data, status])\n\n return { docs: data ?? [], designs }\n}\n\nexport default useHostFormDesigns\n"],"names":["decodeStoredNodes","useFirestore","collection","limit","query","useMemo","useFirestoreCollection","FORMS_PER_CANVAS","useHostFormDesigns","hostId","firestore","data","status","idField","designs","undefined","next","value","nodes","rootId","$id","docs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,+DAA+D;AAC/D,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,OAAOC,4BAA4B,gCAA4B;AAE/D,+EAA+E,GAC/E,MAAMC,mBAAmB;AAiBzB;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,mBACdC,MAA0B;IAE1B,MAAMC,YAAYT;IAClB,MAAM,EAAEU,IAAI,EAAEC,MAAM,EAAE,GAAGN,uBACvB,IACEG,SACIL,MACEF,WAAWQ,WAAW,SAASD,QAAQ,UACvCN,MAAMI,qBAER,MACN;QAACG;QAAWD;KAAO,EACnB;QAAEI,SAAS;IAAM;IAGnB,MAAMC,UAAUT,QAAQ;QACtB,IAAIO,WAAW,WAAW,OAAOG;QACjC,MAAMC,OAA+C,CAAC;QACtD,KAAK,MAAMC,SAASN,eAAAA,OAAQ,EAAE,CAAE;YAC9B,IAAI,EAACM,yBAAAA,MAAOC,KAAK,KAAI,EAACD,yBAAAA,MAAOE,MAAM,GAAE;YACrC,mEAAmE;YACnE,qEAAqE;YACrE,sEAAsE;YACtE,wEAAwE;YACxE,gEAAgE;YAChE,MAAMD,QAAQlB,kBAEZiB,MAAMC,KAAK;YACb,IAAI,EAACA,yBAAAA,KAAO,CAACD,MAAME,MAAM,CAAC,GAAE;YAC5BH,IAAI,CAACC,MAAMG,GAAG,CAAC,GAAG;gBAAED,QAAQF,MAAME,MAAM;gBAAED;YAAM;QAClD;QACA,OAAOF;IACT,GAAG;QAACL;QAAMC;KAAO;IAEjB,OAAO;QAAES,IAAI,EAAEV,eAAAA,OAAQ,EAAE;QAAEG;IAAQ;AACrC;AAEA,eAAeN,mBAAkB"}
|