@igstack/app-catalog-frontend-core 6.10.0 → 6.10.2

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.
@@ -5,10 +5,10 @@ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
5
5
  import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
6
6
  import { AppCatalogGrid } from "../catalog/AppCatalogGrid.js";
7
7
  import { AttributionFooter } from "../catalog/AttributionFooter.js";
8
+ const loadAppDetailPanel = () => import("../catalog/AppDetailPanel.js");
9
+ const preloadAppDetailPanel = () => loadAppDetailPanel().then(() => void 0);
8
10
  const AppDetailPanel = lazy(
9
- () => import("../catalog/AppDetailPanel.js").then((m) => ({
10
- default: m.AppDetailPanel
11
- }))
11
+ () => loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel }))
12
12
  );
13
13
  function AppCatalogPage({
14
14
  selectedSlug,
@@ -49,6 +49,9 @@ function AppCatalogPage({
49
49
  () => rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),
50
50
  [rootResources, filterState.showDeprecated]
51
51
  );
52
+ useEffect(() => {
53
+ void preloadAppDetailPanel();
54
+ }, []);
52
55
  useEffect(() => {
53
56
  }, [isLoadingApps, resources.length, rootResources.length]);
54
57
  useEffect(() => {
@@ -141,6 +144,7 @@ function AppCatalogPage({
141
144
  );
142
145
  }
143
146
  export {
144
- AppCatalogPage
147
+ AppCatalogPage,
148
+ preloadAppDetailPanel
145
149
  };
146
150
  //# sourceMappingURL=AppCatalogPage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n /**\n * What the grid actually shows, memoized because its IDENTITY matters.\n *\n * Filtering inline in the JSX produced a new array on every render, and the\n * grid keys four `useMemo`s off this prop (the slug index, \"new this week\",\n * the browse list and its area grouping). A fresh identity invalidated all\n * four on every keystroke — recomputed even while searching, when the\n * discovery spine they feed is not on screen. The search box lives in a\n * context above this component, so every keystroke re-renders here.\n */\n const visibleRootResources = useMemo(\n () =>\n rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),\n [rootResources, filterState.showDeprecated],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n /**\n * Strip the card's own params once no card is open.\n *\n * Clearing them inside the close handler does not hold: `useUrlSyncedState` only\n * ever pushes its state OUT to the url, so the tab strip — still mounted for one\n * more render — sees the url disagreeing with its own state and writes `tab` straight\n * back. Two owners, and the one on its way out gets the last word. Measured on a\n * live host: `?tab=notes` survived the close every time.\n *\n * Doing it here instead means one owner decides, and it decides AFTER the strip has\n * gone, so there is nothing left to argue with. Only the card's params go; the\n * catalog's own state (filters, recents, the deprecated toggle) outlives any card.\n */\n useEffect(() => {\n if (selectedSlug || subPageSlug) return\n const stale = ['tab', 'sub', 'qj'].filter(\n (key) => (search as Record<string, unknown>)[key] !== undefined,\n )\n if (stale.length === 0) return\n void navigate({\n to: '/',\n replace: true,\n search: (prev) => {\n const next = { ...(prev as Record<string, unknown>) }\n for (const key of stale) delete next[key]\n return next\n },\n })\n }, [selectedSlug, subPageSlug, search, navigate])\n\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={visibleRootResources}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) stays out of the bundle\n// every visitor loads first (#120). The page fetches it after its first paint,\n// so the first click on a row does not wait for the chunk.\nconst loadAppDetailPanel = () => import('../catalog/AppDetailPanel')\n\n/** Resolves once the detail card's chunk is loaded. */\nexport const preloadAppDetailPanel = (): Promise<void> =>\n loadAppDetailPanel().then(() => undefined)\n\nconst AppDetailPanel = lazy(() =>\n loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n /**\n * What the grid actually shows, memoized because its IDENTITY matters.\n *\n * Filtering inline in the JSX produced a new array on every render, and the\n * grid keys four `useMemo`s off this prop (the slug index, \"new this week\",\n * the browse list and its area grouping). A fresh identity invalidated all\n * four on every keystroke — recomputed even while searching, when the\n * discovery spine they feed is not on screen. The search box lives in a\n * context above this component, so every keystroke re-renders here.\n */\n const visibleRootResources = useMemo(\n () =>\n rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),\n [rootResources, filterState.showDeprecated],\n )\n\n useEffect(() => {\n void preloadAppDetailPanel()\n }, [])\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n /**\n * Strip the card's own params once no card is open.\n *\n * Clearing them inside the close handler does not hold: `useUrlSyncedState` only\n * ever pushes its state OUT to the url, so the tab strip — still mounted for one\n * more render — sees the url disagreeing with its own state and writes `tab` straight\n * back. Two owners, and the one on its way out gets the last word. Measured on a\n * live host: `?tab=notes` survived the close every time.\n *\n * Doing it here instead means one owner decides, and it decides AFTER the strip has\n * gone, so there is nothing left to argue with. Only the card's params go; the\n * catalog's own state (filters, recents, the deprecated toggle) outlives any card.\n */\n useEffect(() => {\n if (selectedSlug || subPageSlug) return\n const stale = ['tab', 'sub', 'qj'].filter(\n (key) => (search as Record<string, unknown>)[key] !== undefined,\n )\n if (stale.length === 0) return\n void navigate({\n to: '/',\n replace: true,\n search: (prev) => {\n const next = { ...(prev as Record<string, unknown>) }\n for (const key of stale) delete next[key]\n return next\n },\n })\n }, [selectedSlug, subPageSlug, search, navigate])\n\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={visibleRootResources}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo, createContext, use } from "react";
2
+ import { use, createContext, useState, useEffect, useMemo } from "react";
3
3
  const AuthContext = createContext(void 0);
4
4
  const STORAGE_KEY = "ac_auth_user";
5
5
  function AuthProvider({ children }) {
@@ -10,21 +10,29 @@ function PwaAutoUpdateProvider({
10
10
  children
11
11
  }) {
12
12
  const controllerRef = useRef(void 0);
13
+ const handleRef = useRef(handle);
13
14
  useEffect(() => {
14
- if (!handle) return;
15
- const controller = new PwaAutoUpdateController(handle, options);
15
+ var _a;
16
+ handleRef.current = handle;
17
+ if (handle) (_a = controllerRef.current) == null ? void 0 : _a.updateHandle(handle);
18
+ }, [handle]);
19
+ const hasHandle = handle !== void 0;
20
+ const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {};
21
+ useEffect(() => {
22
+ const current = handleRef.current;
23
+ if (!current) return;
24
+ const controller = new PwaAutoUpdateController(current, {
25
+ idleTimeoutMs,
26
+ minCheckIntervalMs,
27
+ debug
28
+ });
16
29
  controllerRef.current = controller;
17
30
  controller.start();
18
31
  return () => {
19
32
  controller.destroy();
20
33
  controllerRef.current = void 0;
21
34
  };
22
- }, [handle, options]);
23
- useEffect(() => {
24
- if (handle && controllerRef.current) {
25
- controllerRef.current.updateHandle(handle);
26
- }
27
- }, [handle]);
35
+ }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug]);
28
36
  const contextValue = useMemo(
29
37
  () => ({
30
38
  triggerUpdateOnError: () => {
@@ -1 +1 @@
1
- {"version":3,"file":"PwaAutoUpdateContext.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo, useRef } from 'react'\nimport { PwaAutoUpdateController } from './PwaAutoUpdateController'\nimport type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nexport interface PwaAutoUpdateContextValue {\n triggerUpdateOnError: () => void\n}\n\nconst PwaAutoUpdateContext = createContext<PwaAutoUpdateContextValue | null>(\n null,\n)\n\ninterface PwaAutoUpdateProviderProps {\n handle: PwaUpdateHandle | undefined\n options?: PwaAutoUpdateOptions\n children: ReactNode\n}\n\nexport function PwaAutoUpdateProvider({\n handle,\n options,\n children,\n}: PwaAutoUpdateProviderProps) {\n const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)\n\n useEffect(() => {\n if (!handle) return\n\n const controller = new PwaAutoUpdateController(handle, options)\n controllerRef.current = controller\n controller.start()\n\n return () => {\n controller.destroy()\n controllerRef.current = undefined\n }\n }, [handle, options])\n\n // Update handle when registration arrives async\n useEffect(() => {\n if (handle && controllerRef.current) {\n controllerRef.current.updateHandle(handle)\n }\n }, [handle])\n\n const contextValue = useMemo<PwaAutoUpdateContextValue>(\n () => ({\n triggerUpdateOnError: () => {\n controllerRef.current?.triggerUpdateOnError()\n },\n }),\n [],\n )\n\n return (\n <PwaAutoUpdateContext value={contextValue}>{children}</PwaAutoUpdateContext>\n )\n}\n\n/**\n * Returns the PWA auto-update context, or null if outside the provider.\n * Null return allows graceful degradation when PWA is not configured.\n */\nexport function usePwaAutoUpdate(): PwaAutoUpdateContextValue | null {\n return use(PwaAutoUpdateContext)\n}\n"],"names":[],"mappings":";;;AASA,MAAM,uBAAuB;AAAA,EAC3B;AACF;AAQO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAA+B;AAC7B,QAAM,gBAAgB,OAA4C,MAAS;AAE3E,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AAEb,UAAM,aAAa,IAAI,wBAAwB,QAAQ,OAAO;AAC9D,kBAAc,UAAU;AACxB,eAAW,MAAA;AAEX,WAAO,MAAM;AACX,iBAAW,QAAA;AACX,oBAAc,UAAU;AAAA,IAC1B;AAAA,EACF,GAAG,CAAC,QAAQ,OAAO,CAAC;AAGpB,YAAU,MAAM;AACd,QAAI,UAAU,cAAc,SAAS;AACnC,oBAAc,QAAQ,aAAa,MAAM;AAAA,IAC3C;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,sBAAsB,MAAM;;AAC1B,4BAAc,YAAd,mBAAuB;AAAA,MACzB;AAAA,IAAA;AAAA,IAEF,CAAA;AAAA,EAAC;AAGH,SACE,oBAAC,sBAAA,EAAqB,OAAO,cAAe,SAAA,CAAS;AAEzD;AAMO,SAAS,mBAAqD;AACnE,SAAO,IAAI,oBAAoB;AACjC;"}
1
+ {"version":3,"file":"PwaAutoUpdateContext.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo, useRef } from 'react'\nimport { PwaAutoUpdateController } from './PwaAutoUpdateController'\nimport type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nexport interface PwaAutoUpdateContextValue {\n triggerUpdateOnError: () => void\n}\n\nconst PwaAutoUpdateContext = createContext<PwaAutoUpdateContextValue | null>(\n null,\n)\n\ninterface PwaAutoUpdateProviderProps {\n handle: PwaUpdateHandle | undefined\n options?: PwaAutoUpdateOptions\n children: ReactNode\n}\n\nexport function PwaAutoUpdateProvider({\n handle,\n options,\n children,\n}: PwaAutoUpdateProviderProps) {\n const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)\n const handleRef = useRef(handle)\n\n // A new handle (the registration arriving) goes to the running controller.\n // Declared first, so the effect below reads the current handle.\n useEffect(() => {\n handleRef.current = handle\n if (handle) controllerRef.current?.updateHandle(handle)\n }, [handle])\n\n // One controller for as long as there is a handle and the option values\n // hold. Keyed on values, not identities: rebuilding it drops its pending\n // visibility check and its idle tracking.\n const hasHandle = handle !== undefined\n const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {}\n useEffect(() => {\n const current = handleRef.current\n if (!current) return\n\n const controller = new PwaAutoUpdateController(current, {\n idleTimeoutMs,\n minCheckIntervalMs,\n debug,\n })\n controllerRef.current = controller\n controller.start()\n\n return () => {\n controller.destroy()\n controllerRef.current = undefined\n }\n }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug])\n\n const contextValue = useMemo<PwaAutoUpdateContextValue>(\n () => ({\n triggerUpdateOnError: () => {\n controllerRef.current?.triggerUpdateOnError()\n },\n }),\n [],\n )\n\n return (\n <PwaAutoUpdateContext value={contextValue}>{children}</PwaAutoUpdateContext>\n )\n}\n\n/**\n * Returns the PWA auto-update context, or null if outside the provider.\n * Null return allows graceful degradation when PWA is not configured.\n */\nexport function usePwaAutoUpdate(): PwaAutoUpdateContextValue | null {\n return use(PwaAutoUpdateContext)\n}\n"],"names":[],"mappings":";;;AASA,MAAM,uBAAuB;AAAA,EAC3B;AACF;AAQO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAA+B;AAC7B,QAAM,gBAAgB,OAA4C,MAAS;AAC3E,QAAM,YAAY,OAAO,MAAM;AAI/B,YAAU,MAAM;;AACd,cAAU,UAAU;AACpB,QAAI,OAAQ,qBAAc,YAAd,mBAAuB,aAAa;AAAA,EAClD,GAAG,CAAC,MAAM,CAAC;AAKX,QAAM,YAAY,WAAW;AAC7B,QAAM,EAAE,eAAe,oBAAoB,MAAA,IAAU,WAAW,CAAA;AAChE,YAAU,MAAM;AACd,UAAM,UAAU,UAAU;AAC1B,QAAI,CAAC,QAAS;AAEd,UAAM,aAAa,IAAI,wBAAwB,SAAS;AAAA,MACtD;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AACD,kBAAc,UAAU;AACxB,eAAW,MAAA;AAEX,WAAO,MAAM;AACX,iBAAW,QAAA;AACX,oBAAc,UAAU;AAAA,IAC1B;AAAA,EACF,GAAG,CAAC,WAAW,eAAe,oBAAoB,KAAK,CAAC;AAExD,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,sBAAsB,MAAM;;AAC1B,4BAAc,YAAd,mBAAuB;AAAA,MACzB;AAAA,IAAA;AAAA,IAEF,CAAA;AAAA,EAAC;AAGH,SACE,oBAAC,sBAAA,EAAqB,OAAO,cAAe,SAAA,CAAS;AAEzD;AAMO,SAAS,mBAAqD;AACnE,SAAO,IAAI,oBAAoB;AACjC;"}
@@ -1 +1 @@
1
- {"version":3,"file":"Devtools.js","sources":["../../../../src/ui/layout/Devtools.tsx"],"sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { QueryClient } from '@tanstack/react-query'\n\n// Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops\n// the import() and neither devtools package reaches the shipped bundle (#120).\nconst DevtoolsPanels = import.meta.env.DEV\n ? lazy(() => import('./DevtoolsPanels'))\n : null\n\nexport function Devtools({ client }: { client: QueryClient }) {\n if (!DevtoolsPanels) return null\n return (\n <Suspense fallback={null}>\n <DevtoolsPanels client={client} />\n </Suspense>\n )\n}\n"],"names":[],"mappings":";;AASO,SAAS,SAAS,EAAE,UAAmC;AACvC,SAAO;AAM9B;"}
1
+ {"version":3,"file":"Devtools.js","sources":["../../../../src/ui/layout/Devtools.tsx"],"sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { QueryClient } from '@tanstack/react-query'\n\n// Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops\n// the import() and neither devtools package reaches the shipped bundle (#120).\n// Off under vitest too: nobody looks at them there, and every test file would\n// otherwise transform both packages on its first mount.\nconst DevtoolsPanels =\n import.meta.env.DEV && import.meta.env.MODE !== 'test'\n ? lazy(() => import('./DevtoolsPanels'))\n : null\n\nexport function Devtools({ client }: { client: QueryClient }) {\n if (!DevtoolsPanels) return null\n return (\n <Suspense fallback={null}>\n <DevtoolsPanels client={client} />\n </Suspense>\n )\n}\n"],"names":[],"mappings":";;AAYO,SAAS,SAAS,EAAE,UAAmC;AACvC,SAAO;AAM9B;"}
package/dist/index.css CHANGED
@@ -479,31 +479,7 @@
479
479
  Comments come first; each later group sits under a hairline with generous
480
480
  room either side, and a plain title. */
481
481
  .behind-panel {
482
- container-type: inline-size;
483
- container-name: behind;
484
- }
485
- @container behind (min-width: 900px) {
486
- .behind-layout {
487
- display: grid;
488
- grid-template-columns: minmax(0, 68ch) minmax(240px, 1fr);
489
- column-gap: 3.5rem;
490
- }
491
- .behind-main {
492
- grid-column: 1;
493
- }
494
- .behind-aside {
495
- grid-column: 2;
496
- }
497
- .behind-aside .behind-group {
498
- margin-top: 0;
499
- padding-top: 0;
500
- border-top: none;
501
- }
502
- .behind-aside .behind-group + .behind-group {
503
- margin-top: 2.5rem;
504
- padding-top: 0;
505
- border-top: none;
506
- }
482
+ max-width: 62ch;
507
483
  }
508
484
  .behind-group + .behind-group {
509
485
  margin-top: 3.5rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.10.0",
3
+ "version": "6.10.2",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -118,7 +118,7 @@
118
118
  "tailwindcss": "^4.1.7",
119
119
  "tiptap-markdown": "^0.8.0",
120
120
  "tslib": "^2.3.0",
121
- "@igstack/app-catalog-shared-core": "6.10.0"
121
+ "@igstack/app-catalog-shared-core": "6.10.2"
122
122
  },
123
123
  "devDependencies": {
124
124
  "@tailwindcss/vite": "^4.0.5",
@@ -146,7 +146,7 @@
146
146
  "vite-plugin-static-copy": "^3.1.4",
147
147
  "vite-plugin-svgr": "^4.2.0",
148
148
  "vitest": "^4.1.2",
149
- "@igstack/app-catalog-backend-core": "6.10.0"
149
+ "@igstack/app-catalog-backend-core": "6.10.2"
150
150
  },
151
151
  "peerDependencies": {
152
152
  "@tanstack/react-router": "1.170.41",
@@ -156,7 +156,7 @@
156
156
  "vite": "^6.3.5",
157
157
  "vite-plugin-svgr": "^4.2.0"
158
158
  },
159
- "gitHead": "8a9a5fb554ef3414c474c608d32f830907dfcc72",
159
+ "gitHead": "f3e5b024e83e55773503820fdb5bfd858319b6d1",
160
160
  "scripts": {
161
161
  "build": "vite build",
162
162
  "build:lenient": "vite build --mode lenient",
@@ -1,5 +1,5 @@
1
1
  import '@testing-library/jest-dom/vitest'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
3
  import userEvent from '@testing-library/user-event'
4
4
  import { describe, expect, it, vi } from 'vitest'
5
5
  import { useEffect, useRef, useState } from 'react'
@@ -322,10 +322,8 @@ describe('the dismiss stack', () => {
322
322
  screen.getByTestId('layer-dialog').focus()
323
323
 
324
324
  await esc()
325
- // The handback is queued after the unmount, so let it run.
326
- await new Promise((r) => setTimeout(r, 0))
327
-
328
- expect(document.activeElement).toBe(screen.getByTestId('layer-card'))
325
+ // The handback is queued after the unmount.
326
+ await waitFor(() => expect(screen.getByTestId('layer-card')).toHaveFocus())
329
327
  })
330
328
 
331
329
  /** A layer that takes the caret on mount, as the detail card does. */
@@ -374,9 +372,8 @@ describe('the dismiss stack', () => {
374
372
  expect(screen.getByTestId('layer-card')).toHaveFocus()
375
373
 
376
374
  await esc()
377
- await new Promise((r) => setTimeout(r, 0))
378
375
 
379
- expect(input).toHaveFocus()
376
+ await waitFor(() => expect(input).toHaveFocus())
380
377
  expect([input.selectionStart, input.selectionEnd]).toEqual([13, 13])
381
378
  })
382
379
 
@@ -405,16 +402,17 @@ describe('the dismiss stack', () => {
405
402
  )
406
403
  }
407
404
  render(<Host />)
408
- const tick = () => new Promise((r) => setTimeout(r, 0))
409
405
 
410
406
  await userEvent.click(screen.getByRole('button', { name: 'open card' }))
411
407
  await userEvent.click(screen.getByRole('button', { name: 'open dialog' }))
412
408
  await esc()
413
- await tick()
414
- expect(screen.getByRole('button', { name: 'open dialog' })).toHaveFocus()
409
+ await waitFor(() =>
410
+ expect(screen.getByRole('button', { name: 'open dialog' })).toHaveFocus(),
411
+ )
415
412
 
416
413
  await esc()
417
- await tick()
418
- expect(screen.getByRole('button', { name: 'open card' })).toHaveFocus()
414
+ await waitFor(() =>
415
+ expect(screen.getByRole('button', { name: 'open card' })).toHaveFocus(),
416
+ )
419
417
  })
420
418
  })
@@ -0,0 +1,44 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { PwaAutoUpdateController } from '~/modules/pwa/PwaAutoUpdateController'
4
+ import { PwaAutoUpdateProvider } from '~/modules/pwa/PwaAutoUpdateContext'
5
+ import type { PwaUpdateHandle } from '~/modules/pwa/types'
6
+
7
+ const handle = (registration?: ServiceWorkerRegistration): PwaUpdateHandle => ({
8
+ updateSW: vi.fn(),
9
+ registration,
10
+ })
11
+
12
+ describe('PwaAutoUpdateProvider', () => {
13
+ afterEach(() => {
14
+ vi.restoreAllMocks()
15
+ })
16
+
17
+ it('keeps one controller while the registration arrives and the options are re-created', () => {
18
+ // The host app passes a handle without a registration first, then a new
19
+ // one once the worker registers, and an inline options object every
20
+ // render. A rebuilt controller drops its pending visibility check.
21
+ const start = vi.spyOn(PwaAutoUpdateController.prototype, 'start')
22
+ const destroy = vi.spyOn(PwaAutoUpdateController.prototype, 'destroy')
23
+ const updateHandle = vi.spyOn(
24
+ PwaAutoUpdateController.prototype,
25
+ 'updateHandle',
26
+ )
27
+ const registered = handle({} as ServiceWorkerRegistration)
28
+
29
+ const { rerender } = render(
30
+ <PwaAutoUpdateProvider handle={handle()} options={{ debug: false }}>
31
+ <div />
32
+ </PwaAutoUpdateProvider>,
33
+ )
34
+ rerender(
35
+ <PwaAutoUpdateProvider handle={registered} options={{ debug: false }}>
36
+ <div />
37
+ </PwaAutoUpdateProvider>,
38
+ )
39
+
40
+ expect(start).toHaveBeenCalledTimes(1)
41
+ expect(destroy).not.toHaveBeenCalled()
42
+ expect(updateHandle).toHaveBeenLastCalledWith(registered)
43
+ })
44
+ })
package/src/index.css CHANGED
@@ -479,31 +479,7 @@
479
479
  Comments come first; each later group sits under a hairline with generous
480
480
  room either side, and a plain title. */
481
481
  .behind-panel {
482
- container-type: inline-size;
483
- container-name: behind;
484
- }
485
- @container behind (min-width: 900px) {
486
- .behind-layout {
487
- display: grid;
488
- grid-template-columns: minmax(0, 68ch) minmax(240px, 1fr);
489
- column-gap: 3.5rem;
490
- }
491
- .behind-main {
492
- grid-column: 1;
493
- }
494
- .behind-aside {
495
- grid-column: 2;
496
- }
497
- .behind-aside .behind-group {
498
- margin-top: 0;
499
- padding-top: 0;
500
- border-top: none;
501
- }
502
- .behind-aside .behind-group + .behind-group {
503
- margin-top: 2.5rem;
504
- padding-top: 0;
505
- border-top: none;
506
- }
482
+ max-width: 62ch;
507
483
  }
508
484
  .behind-group + .behind-group {
509
485
  margin-top: 3.5rem;
package/src/internal.ts CHANGED
@@ -21,3 +21,4 @@ export {
21
21
  clearSessionState,
22
22
  seedSessionState,
23
23
  } from './modules/appCatalog/hooks/useSessionSyncedState'
24
+ export { preloadAppDetailPanel } from './modules/appCatalog/ui/pages/AppCatalogPage'