@asteby/metacore-runtime-react 16.0.0 → 17.0.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
@@ -1,5 +1,39 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 17.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - c834e67: Render the nil UUID (`00000000-0000-0000-0000-000000000000`) as empty in
8
+ dynamic tables and the detail view.
9
+
10
+ A nullable FK that a backend serializes as the all-zeros UUID instead of `null`
11
+ used to leak into cells and read-only fields as a long string of zeros. The
12
+ table cell renderer (`defaultGetDynamicColumns`) and the record detail view
13
+ (`DynamicRecordDialog`/`ViewRecordDialog`) now treat the nil UUID as "no value",
14
+ falling through to their existing empty markers (`-` / `—`). This covers
15
+ relation/ref chips, `creator`/`url`/`status`/`dynamic_select` and any generic
16
+ UUID-bearing column. A new shared guard (`NIL_UUID`, `isNilUuid`,
17
+ `normalizeNilUuid`) is exported for hosts that render values themselves.
18
+
19
+ - Updated dependencies [8a4a315]
20
+ - @asteby/metacore-sdk@3.2.0
21
+
22
+ ## 16.0.1
23
+
24
+ ### Patch Changes
25
+
26
+ - cde025c: Fix intermittent `#RUNTIME-009` ("Please call createInstance first") addon load
27
+ errors.
28
+
29
+ When an addon mounts before `@module-federation/vite`'s asynchronous runtime
30
+ init lands at host boot, `registerRemotes`/`loadRemote` throw RUNTIME-009. The
31
+ runtime is on its way — it's a boot race — so `AddonLoader` now treats that
32
+ specific error as transient and retries with a short backoff (~10 × 60ms) until
33
+ the host's federation runtime is ready, instead of surfacing a dead "Addon load
34
+ error" to the user. Genuine failures (bad URL, missing export, 404) still
35
+ rethrow immediately.
36
+
3
37
  ## 16.0.0
4
38
 
5
39
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,GAAG,EAAE,QAAQ,CAAA;IACb,wCAAwC;IACxC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AA6CD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CA8ClB"}
1
+ {"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,GAAG,EAAE,QAAQ,CAAA;IACb,wCAAwC;IACxC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AAoFD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CA8ClB"}
@@ -25,22 +25,56 @@ function remoteId(scope, module) {
25
25
  const expose = module.replace(/^\.\//, '');
26
26
  return `${scope}/${expose}`;
27
27
  }
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ // `@module-federation/vite` initialises the shared federation runtime instance
30
+ // asynchronously at host boot (it injects an init call into the entry). If an
31
+ // addon mounts before that init resolves — a real race on slow first paints,
32
+ // route preloads, or HMR — `registerRemotes`/`loadRemote` throw
33
+ // `[ Federation Runtime ]: Please call createInstance first. #RUNTIME-009`.
34
+ // It's transient: the runtime IS coming, we just raced it. So we treat
35
+ // RUNTIME-009 specifically as retryable and back off briefly until the host's
36
+ // init lands, instead of surfacing a dead "Addon load error" to the user.
37
+ function isRuntimeNotReady(e) {
38
+ const msg = e instanceof Error ? e.message : String(e);
39
+ return msg.includes('#RUNTIME-009') || msg.includes('call createInstance');
40
+ }
41
+ // Retry an operation that may hit the boot race above. ~10 attempts × 60ms ≈
42
+ // 600ms worst case — generous for the host init, imperceptible in the common
43
+ // case (first attempt succeeds). Non-RUNTIME-009 errors (bad URL, 404, no
44
+ // export) rethrow immediately so genuine failures still surface fast.
45
+ async function withRuntimeReady(op) {
46
+ const maxAttempts = 10;
47
+ for (let attempt = 1;; attempt++) {
48
+ try {
49
+ return await op();
50
+ }
51
+ catch (e) {
52
+ if (!isRuntimeNotReady(e) || attempt >= maxAttempts)
53
+ throw e;
54
+ await sleep(60);
55
+ }
56
+ }
57
+ }
28
58
  async function loadAddon(scope, url, module) {
29
59
  // Register the remote container as an ES module. `type: 'module'` matches
30
60
  // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
31
61
  // The `url` already carries the `?v=` cache-bust the host computed, so the
32
62
  // browser refetches a fresh remoteEntry when the addon version changes.
63
+ //
64
+ // Both calls are wrapped in `withRuntimeReady` because EITHER can throw
65
+ // RUNTIME-009 when an addon mounts ahead of the host's federation init —
66
+ // registration is what actually touches the (maybe-uninitialised) runtime.
33
67
  if (!registered.has(scope)) {
34
- registerRemotes([{ name: scope, entry: url, type: 'module' }],
68
+ await withRuntimeReady(() => registerRemotes([{ name: scope, entry: url, type: 'module' }],
35
69
  // `force: true` so a re-registration with a new `?v=` URL (addon
36
70
  // hot-swap / version bump) overwrites the stale entry + cache.
37
- { force: true });
71
+ { force: true }));
38
72
  registered.add(scope);
39
73
  }
40
74
  // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
41
75
  // null if it can't be resolved). No manual share-scope init — the host's
42
76
  // federation runtime already initialised it.
43
- return loadRemote(remoteId(scope, module));
77
+ return withRuntimeReady(() => loadRemote(remoteId(scope, module)));
44
78
  }
45
79
  export function AddonLoader({ scope, url, module = './register', api, fallback = null, onReady, onError, layout, children, }) {
46
80
  const [status, setStatus] = useState('loading');
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAwF1C,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAsDD,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,GACT,EAAE,wBAAwB,2CAsP1B"}
1
+ {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAyF1C,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAwDD,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,GACT,EAAE,wBAAwB,2CAsP1B"}
@@ -14,10 +14,13 @@ import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as X
14
14
  import { useApi } from '../api-context';
15
15
  import { DynamicSelectField } from '../dynamic-select-field';
16
16
  import { getFieldRef } from '../dynamic-form-schema';
17
+ import { normalizeNilUuid } from '../nil-uuid';
17
18
  function resolvePath(obj, path) {
18
19
  return path.split('.').reduce((acc, part) => acc?.[part], obj);
19
20
  }
20
- function formatDisplayValue(value, field) {
21
+ function formatDisplayValue(rawValue, field) {
22
+ // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
23
+ const value = normalizeNilUuid(rawValue);
21
24
  if (value === null || value === undefined || value === '')
22
25
  return '—';
23
26
  if (field.type === 'boolean' || typeof value === 'boolean')
@@ -227,7 +230,10 @@ function FieldRow({ field, record, value, mode, onChange }) {
227
230
  const isReadonly = field.readonly || mode === 'view';
228
231
  return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), isReadonly ? (_jsx(ViewValue, { field: field, value: value, record: record })) : (_jsx(EditField, { field: field, value: value, onChange: onChange }))] }));
229
232
  }
230
- function ViewValue({ field, value }) {
233
+ function ViewValue({ field, value: rawValue }) {
234
+ // Normalize the nil UUID to undefined up front so the search/url/color/
235
+ // image/select branches all fall through to their empty states.
236
+ const value = normalizeNilUuid(rawValue);
231
237
  if (field.type === 'search' && value) {
232
238
  return _jsx(SearchViewValue, { field: field, value: value });
233
239
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AA8CA,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAgGD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAwHD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAStE,CAAA;AAiED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAgmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AA+CA,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAgGD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAwHD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAiED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAmmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -23,6 +23,7 @@ import { generateBadgeStyles, getInitials, optionColor, relationChipStyles, } fr
23
23
  import { Progress } from './dialogs/_primitives';
24
24
  import { OptionsContext } from './options-context';
25
25
  import { DynamicIcon } from './dynamic-icon';
26
+ import { isNilUuid, normalizeNilUuid } from './nil-uuid';
26
27
  import { isColumnVisibleInTable } from './column-visibility';
27
28
  const defaultGetImageUrl = (path) => path;
28
29
  const getNestedValue = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj);
@@ -213,7 +214,10 @@ export const resolveRelationLabel = (col, row) => {
213
214
  if (label !== undefined && label !== null && label !== '')
214
215
  return String(label);
215
216
  const raw = getNestedValue(row, col.key);
216
- return raw !== undefined && raw !== null ? String(raw) : '';
217
+ // An unresolved FK that arrived as the nil UUID reads as empty, not zeros.
218
+ if (raw === undefined || raw === null || isNilUuid(raw))
219
+ return '';
220
+ return String(raw);
217
221
  };
218
222
  /**
219
223
  * Renders a resolved FK relation as a clean, truncated chip. Reads the
@@ -290,7 +294,10 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
290
294
  meta: columnMeta,
291
295
  header: ({ column }) => filterConfig ? (_jsx(FilterableColumnHeader, { column: column, title: translatedLabel })) : (_jsx(DataTableColumnHeader, { column: column, title: translatedLabel })),
292
296
  cell: ({ row }) => {
293
- const value = getNestedValue(row.original, col.key);
297
+ // Treat the nil UUID (unset nullable FK serialized as
298
+ // all-zeros) as no value, so every type below hits its
299
+ // existing empty branch instead of printing the zeros.
300
+ const value = normalizeNilUuid(getNestedValue(row.original, col.key));
294
301
  // Kernel emits the renderer flag as `type`; older hosts used
295
302
  // `cellStyle`. Accept both so a single backend works across
296
303
  // SDK versions.
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederation
17
17
  export * from './dynamic-icon';
18
18
  export type { ColumnFilterConfig, FilterOption as DynamicColumnFilterOption, GetDynamicColumns, DynamicIconComponent, } from './dynamic-columns-shim';
19
19
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, relationKeyFor, resolveRelationLabel, type DynamicColumnsHelpers, } from './dynamic-columns';
20
+ export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid';
20
21
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
21
22
  export { CreateRecordDialog } from './dialogs/create-record-dialog';
22
23
  export { ViewRecordDialog } from './dialogs/view-record-dialog';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ export { ADDON_MANIFEST_CHANGED_TYPE, wireHotSwapInvalidation, useManifestHotSwa
21
21
  export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederationContainer, shortenHash, } from './hotswap-reload-policy';
22
22
  export * from './dynamic-icon';
23
23
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, relationKeyFor, resolveRelationLabel, } from './dynamic-columns';
24
+ export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid';
24
25
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
25
26
  export { CreateRecordDialog } from './dialogs/create-record-dialog';
26
27
  export { ViewRecordDialog } from './dialogs/view-record-dialog';
@@ -0,0 +1,15 @@
1
+ /** The canonical nil/zero UUID sentinel. */
2
+ export declare const NIL_UUID = "00000000-0000-0000-0000-000000000000";
3
+ /**
4
+ * True when `value` is the nil UUID string. Tolerant of surrounding whitespace
5
+ * and letter-case (UUIDs are conventionally lowercase, but be defensive). Only
6
+ * matches strings — numeric/object values are never the nil UUID.
7
+ */
8
+ export declare const isNilUuid: (value: unknown) => boolean;
9
+ /**
10
+ * Normalizes a raw cell value: returns `undefined` when it is the nil UUID so
11
+ * downstream renderers hit their existing nullish/empty branches; otherwise the
12
+ * value is passed through unchanged.
13
+ */
14
+ export declare const normalizeNilUuid: <T>(value: T) => T | undefined;
15
+ //# sourceMappingURL=nil-uuid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nil-uuid.d.ts","sourceRoot":"","sources":["../src/nil-uuid.ts"],"names":[],"mappings":"AAOA,4CAA4C;AAC5C,eAAO,MAAM,QAAQ,yCAAyC,CAAA;AAE9D;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,OAC6B,CAAA;AAExE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,OAAO,CAAC,KAAG,CAAC,GAAG,SACX,CAAA"}
@@ -0,0 +1,20 @@
1
+ // Shared guard for the "nil UUID" — the all-zeros UUID Postgres/Go emit for a
2
+ // nullable FK that was never set (`00000000-0000-0000-0000-000000000000`).
3
+ // Backends sometimes serialize an unset `uuid` column as this sentinel instead
4
+ // of `null`, which then leaks into the UI as a long string of zeros. Treat it
5
+ // as "no value" so cell/detail renderers fall through to their existing empty
6
+ // markers ("-" / "—"). Cosmetic defense-in-depth — the backend should emit null.
7
+ /** The canonical nil/zero UUID sentinel. */
8
+ export const NIL_UUID = '00000000-0000-0000-0000-000000000000';
9
+ /**
10
+ * True when `value` is the nil UUID string. Tolerant of surrounding whitespace
11
+ * and letter-case (UUIDs are conventionally lowercase, but be defensive). Only
12
+ * matches strings — numeric/object values are never the nil UUID.
13
+ */
14
+ export const isNilUuid = (value) => typeof value === 'string' && value.trim().toLowerCase() === NIL_UUID;
15
+ /**
16
+ * Normalizes a raw cell value: returns `undefined` when it is the nil UUID so
17
+ * downstream renderers hit their existing nullish/empty branches; otherwise the
18
+ * value is passed through unchanged.
19
+ */
20
+ export const normalizeNilUuid = (value) => isNilUuid(value) ? undefined : value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "16.0.0",
3
+ "version": "17.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  "lucide-react": ">=0.460",
34
34
  "date-fns": ">=3",
35
35
  "react-day-picker": ">=8",
36
- "@asteby/metacore-sdk": "^3.1.0",
36
+ "@asteby/metacore-sdk": "^3.2.0",
37
37
  "@asteby/metacore-ui": "^2.4.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
@@ -61,7 +61,7 @@
61
61
  "typescript": "^6.0.0",
62
62
  "vitest": "^4.0.0",
63
63
  "zustand": "^5.0.0",
64
- "@asteby/metacore-sdk": "3.1.0",
64
+ "@asteby/metacore-sdk": "3.2.0",
65
65
  "@asteby/metacore-ui": "2.4.0"
66
66
  },
67
67
  "scripts": {
@@ -0,0 +1,42 @@
1
+ // Locks the nil-UUID guard shared by the table cell renderer and the detail
2
+ // view. The all-zeros UUID is the sentinel a backend emits for an unset
3
+ // nullable FK; the UI must read it as "no value", never as a string of zeros.
4
+ import { describe, it, expect } from 'vitest'
5
+ import { NIL_UUID, isNilUuid, normalizeNilUuid } from '../nil-uuid'
6
+
7
+ describe('isNilUuid', () => {
8
+ it('matches the canonical nil UUID', () => {
9
+ expect(isNilUuid(NIL_UUID)).toBe(true)
10
+ expect(isNilUuid('00000000-0000-0000-0000-000000000000')).toBe(true)
11
+ })
12
+
13
+ it('is tolerant of whitespace and case', () => {
14
+ expect(isNilUuid(' 00000000-0000-0000-0000-000000000000 ')).toBe(true)
15
+ expect(isNilUuid('00000000-0000-0000-0000-000000000000'.toUpperCase())).toBe(true)
16
+ })
17
+
18
+ it('does not match a real UUID', () => {
19
+ expect(isNilUuid('3f2504e0-4f89-11d3-9a0c-0305e82c3301')).toBe(false)
20
+ })
21
+
22
+ it('does not match non-string values', () => {
23
+ expect(isNilUuid(null)).toBe(false)
24
+ expect(isNilUuid(undefined)).toBe(false)
25
+ expect(isNilUuid(0)).toBe(false)
26
+ expect(isNilUuid('')).toBe(false)
27
+ expect(isNilUuid({ value: NIL_UUID })).toBe(false)
28
+ })
29
+ })
30
+
31
+ describe('normalizeNilUuid', () => {
32
+ it('maps the nil UUID to undefined', () => {
33
+ expect(normalizeNilUuid(NIL_UUID)).toBeUndefined()
34
+ })
35
+
36
+ it('passes through real values unchanged', () => {
37
+ expect(normalizeNilUuid('real-id')).toBe('real-id')
38
+ expect(normalizeNilUuid(42)).toBe(42)
39
+ expect(normalizeNilUuid(null)).toBeNull()
40
+ expect(normalizeNilUuid('')).toBe('')
41
+ })
42
+ })
@@ -50,4 +50,17 @@ describe('resolveRelationLabel', () => {
50
50
  expect(resolveRelationLabel(col({ ref: 'categories' }), {})).toBe('')
51
51
  expect(resolveRelationLabel(col({ ref: 'categories' }), { category_id: null })).toBe('')
52
52
  })
53
+
54
+ it('treats an unresolved nil UUID FK as empty, not a string of zeros', () => {
55
+ const row = { category_id: '00000000-0000-0000-0000-000000000000' }
56
+ expect(resolveRelationLabel(col({ ref: 'categories' }), row)).toBe('')
57
+ })
58
+
59
+ it('still prefers a resolved sibling label even if the FK id is the nil UUID', () => {
60
+ const row = {
61
+ category_id: '00000000-0000-0000-0000-000000000000',
62
+ category: { value: '00000000-0000-0000-0000-000000000000', label: 'Sin categoría' },
63
+ }
64
+ expect(resolveRelationLabel(col({ ref: 'categories' }), row)).toBe('Sin categoría')
65
+ })
53
66
  })
@@ -64,6 +64,37 @@ function remoteId(scope: string, module: string): string {
64
64
  return `${scope}/${expose}`
65
65
  }
66
66
 
67
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
68
+
69
+ // `@module-federation/vite` initialises the shared federation runtime instance
70
+ // asynchronously at host boot (it injects an init call into the entry). If an
71
+ // addon mounts before that init resolves — a real race on slow first paints,
72
+ // route preloads, or HMR — `registerRemotes`/`loadRemote` throw
73
+ // `[ Federation Runtime ]: Please call createInstance first. #RUNTIME-009`.
74
+ // It's transient: the runtime IS coming, we just raced it. So we treat
75
+ // RUNTIME-009 specifically as retryable and back off briefly until the host's
76
+ // init lands, instead of surfacing a dead "Addon load error" to the user.
77
+ function isRuntimeNotReady(e: unknown): boolean {
78
+ const msg = e instanceof Error ? e.message : String(e)
79
+ return msg.includes('#RUNTIME-009') || msg.includes('call createInstance')
80
+ }
81
+
82
+ // Retry an operation that may hit the boot race above. ~10 attempts × 60ms ≈
83
+ // 600ms worst case — generous for the host init, imperceptible in the common
84
+ // case (first attempt succeeds). Non-RUNTIME-009 errors (bad URL, 404, no
85
+ // export) rethrow immediately so genuine failures still surface fast.
86
+ async function withRuntimeReady<T>(op: () => T | Promise<T>): Promise<T> {
87
+ const maxAttempts = 10
88
+ for (let attempt = 1; ; attempt++) {
89
+ try {
90
+ return await op()
91
+ } catch (e) {
92
+ if (!isRuntimeNotReady(e) || attempt >= maxAttempts) throw e
93
+ await sleep(60)
94
+ }
95
+ }
96
+ }
97
+
67
98
  async function loadAddon(
68
99
  scope: string,
69
100
  url: string,
@@ -73,19 +104,27 @@ async function loadAddon(
73
104
  // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
74
105
  // The `url` already carries the `?v=` cache-bust the host computed, so the
75
106
  // browser refetches a fresh remoteEntry when the addon version changes.
107
+ //
108
+ // Both calls are wrapped in `withRuntimeReady` because EITHER can throw
109
+ // RUNTIME-009 when an addon mounts ahead of the host's federation init —
110
+ // registration is what actually touches the (maybe-uninitialised) runtime.
76
111
  if (!registered.has(scope)) {
77
- registerRemotes(
78
- [{ name: scope, entry: url, type: 'module' }],
79
- // `force: true` so a re-registration with a new `?v=` URL (addon
80
- // hot-swap / version bump) overwrites the stale entry + cache.
81
- { force: true },
112
+ await withRuntimeReady(() =>
113
+ registerRemotes(
114
+ [{ name: scope, entry: url, type: 'module' }],
115
+ // `force: true` so a re-registration with a new `?v=` URL (addon
116
+ // hot-swap / version bump) overwrites the stale entry + cache.
117
+ { force: true },
118
+ ),
82
119
  )
83
120
  registered.add(scope)
84
121
  }
85
122
  // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
86
123
  // null if it can't be resolved). No manual share-scope init — the host's
87
124
  // federation runtime already initialised it.
88
- return loadRemote<AddonRegisterModule>(remoteId(scope, module))
125
+ return withRuntimeReady(() =>
126
+ loadRemote<AddonRegisterModule>(remoteId(scope, module)),
127
+ )
89
128
  }
90
129
 
91
130
  export function AddonLoader({
@@ -42,6 +42,7 @@ import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as X
42
42
  import { useApi } from '../api-context'
43
43
  import { DynamicSelectField } from '../dynamic-select-field'
44
44
  import { getFieldRef } from '../dynamic-form-schema'
45
+ import { normalizeNilUuid } from '../nil-uuid'
45
46
  import type { ActionFieldDef } from '../types'
46
47
 
47
48
  interface FieldOption {
@@ -137,7 +138,9 @@ function resolvePath(obj: any, path: string): any {
137
138
  return path.split('.').reduce((acc, part) => acc?.[part], obj)
138
139
  }
139
140
 
140
- function formatDisplayValue(value: any, field: FieldDef): string {
141
+ function formatDisplayValue(rawValue: any, field: FieldDef): string {
142
+ // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
143
+ const value = normalizeNilUuid(rawValue)
141
144
  if (value === null || value === undefined || value === '') return '—'
142
145
  if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No'
143
146
 
@@ -489,7 +492,10 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
489
492
  )
490
493
  }
491
494
 
492
- function ViewValue({ field, value }: { field: FieldDef; value: any; record: any }) {
495
+ function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; record: any }) {
496
+ // Normalize the nil UUID to undefined up front so the search/url/color/
497
+ // image/select branches all fall through to their empty states.
498
+ const value = normalizeNilUuid(rawValue)
493
499
  if (field.type === 'search' && value) {
494
500
  return <SearchViewValue field={field} value={value} />
495
501
  }
@@ -44,6 +44,7 @@ import {
44
44
  import { Progress } from './dialogs/_primitives'
45
45
  import { OptionsContext } from './options-context'
46
46
  import { DynamicIcon } from './dynamic-icon'
47
+ import { isNilUuid, normalizeNilUuid } from './nil-uuid'
47
48
  import type { TableMetadata, ColumnDefinition } from './types'
48
49
  import { isColumnVisibleInTable } from './column-visibility'
49
50
  import type {
@@ -328,7 +329,9 @@ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string =>
328
329
  : undefined
329
330
  if (label !== undefined && label !== null && label !== '') return String(label)
330
331
  const raw = getNestedValue(row, col.key)
331
- return raw !== undefined && raw !== null ? String(raw) : ''
332
+ // An unresolved FK that arrived as the nil UUID reads as empty, not zeros.
333
+ if (raw === undefined || raw === null || isNilUuid(raw)) return ''
334
+ return String(raw)
332
335
  }
333
336
 
334
337
  /**
@@ -476,7 +479,10 @@ export function makeDefaultGetDynamicColumns(
476
479
  <DataTableColumnHeader column={column} title={translatedLabel} />
477
480
  ),
478
481
  cell: ({ row }) => {
479
- const value = getNestedValue(row.original, col.key)
482
+ // Treat the nil UUID (unset nullable FK serialized as
483
+ // all-zeros) as no value, so every type below hits its
484
+ // existing empty branch instead of printing the zeros.
485
+ const value = normalizeNilUuid(getNestedValue(row.original, col.key))
480
486
  // Kernel emits the renderer flag as `type`; older hosts used
481
487
  // `cellStyle`. Accept both so a single backend works across
482
488
  // SDK versions.
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export {
66
66
  resolveRelationLabel,
67
67
  type DynamicColumnsHelpers,
68
68
  } from './dynamic-columns'
69
+ export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid'
69
70
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
70
71
  export { CreateRecordDialog } from './dialogs/create-record-dialog'
71
72
  export { ViewRecordDialog } from './dialogs/view-record-dialog'
@@ -0,0 +1,25 @@
1
+ // Shared guard for the "nil UUID" — the all-zeros UUID Postgres/Go emit for a
2
+ // nullable FK that was never set (`00000000-0000-0000-0000-000000000000`).
3
+ // Backends sometimes serialize an unset `uuid` column as this sentinel instead
4
+ // of `null`, which then leaks into the UI as a long string of zeros. Treat it
5
+ // as "no value" so cell/detail renderers fall through to their existing empty
6
+ // markers ("-" / "—"). Cosmetic defense-in-depth — the backend should emit null.
7
+
8
+ /** The canonical nil/zero UUID sentinel. */
9
+ export const NIL_UUID = '00000000-0000-0000-0000-000000000000'
10
+
11
+ /**
12
+ * True when `value` is the nil UUID string. Tolerant of surrounding whitespace
13
+ * and letter-case (UUIDs are conventionally lowercase, but be defensive). Only
14
+ * matches strings — numeric/object values are never the nil UUID.
15
+ */
16
+ export const isNilUuid = (value: unknown): boolean =>
17
+ typeof value === 'string' && value.trim().toLowerCase() === NIL_UUID
18
+
19
+ /**
20
+ * Normalizes a raw cell value: returns `undefined` when it is the nil UUID so
21
+ * downstream renderers hit their existing nullish/empty branches; otherwise the
22
+ * value is passed through unchanged.
23
+ */
24
+ export const normalizeNilUuid = <T>(value: T): T | undefined =>
25
+ isNilUuid(value) ? undefined : value