Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.24% covered (warning)
88.24%
30 / 34
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sidebar_Open_Preservation
88.24% covered (warning)
88.24%
30 / 34
83.33% covered (warning)
83.33%
5 / 6
17.47
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_preopen_body_classes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 print_sidebar_docking_gate_script
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
8.81
 should_pre_render_docked_shell
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 should_preserve_sidebar_open_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Sidebar Open Preservation file.
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8namespace Automattic\Jetpack\Agents_Manager;
9
10/**
11 * Preserves the Agents Manager sidebar-open body classes across full wp-admin
12 * navigations so the next page load can pre-apply them server-side (avoiding a
13 * flicker before the React app boots).
14 *
15 * The open state comes from Open_State_Store::get_cached(), and the pre-render only
16 * runs when the Agents Manager app is actually loading on this request — so the
17 * pre-rendered shell is always reconciled by the app that mounts to manage it,
18 * never left orphaned.
19 *
20 * The server can know the persisted "open && docked" preference, but not the
21 * live viewport — and the docked layout only fits above a width breakpoint and
22 * when the admin menu fits vertically. Width is handled in CSS (a static media
23 * query). Height cannot be: the threshold is the *measured* #adminmenu height,
24 * which varies per page. So a tiny synchronous viewport-height gate is printed on
25 * `in_admin_header` (which fires after #adminmenu is in the DOM, before the
26 * content paints) to re-evaluate the real dock gate and strip the pre-rendered
27 * classes when the chat will actually float — pre-paint, so there is no flash.
28 */
29class Sidebar_Open_Preservation {
30    /**
31     * Class instance.
32     *
33     * @var Sidebar_Open_Preservation
34     */
35    private static $instance;
36
37    /**
38     * Body class marking the docked sidebar shell.
39     *
40     * @var string
41     */
42    private const SIDEBAR_CONTAINER_CLASS = 'agents-manager-sidebar-container';
43
44    /**
45     * Body class marking the sidebar as open.
46     *
47     * @var string
48     */
49    private const SIDEBAR_OPEN_CLASS = 'agents-manager-sidebar-container--sidebar-open';
50
51    /**
52     * Creates instance.
53     *
54     * @return void
55     */
56    public static function init() {
57        if ( self::$instance === null ) {
58            self::$instance = new self();
59        }
60    }
61
62    /**
63     * Sidebar_Open_Preservation constructor.
64     */
65    public function __construct() {
66        // Run last so our class sits at the end of the `admin_body_class` list. Otherwise
67        // a later filter could append its class without a leading space and glue it onto
68        // ours, breaking the CSS selector that pre-opens the sidebar.
69        add_filter( 'admin_body_class', array( $this, 'add_preopen_body_classes' ), PHP_INT_MAX );
70
71        // Reconcile the pre-rendered shell against the live viewport before the
72        // page content paints. `in_admin_header` fires after #adminmenu is in the
73        // DOM, so the script can measure it the same way the React app does.
74        add_action( 'in_admin_header', array( $this, 'print_sidebar_docking_gate_script' ) );
75    }
76
77    /**
78     * Inject pre-open assistant classes in initial admin body markup.
79     *
80     * @param string $classes Existing admin body classes.
81     * @return string
82     */
83    public function add_preopen_body_classes( string $classes ): string {
84        if ( ! $this->should_pre_render_docked_shell() ) {
85            return $classes;
86        }
87
88        $body_classes_with_sidebar_classes = implode(
89            ' ',
90            array_filter(
91                array(
92                    $classes,
93                    self::SIDEBAR_CONTAINER_CLASS,
94                    self::SIDEBAR_OPEN_CLASS,
95                )
96            )
97        );
98
99        return ' ' . $body_classes_with_sidebar_classes . ' ';
100    }
101
102    /**
103     * Print the synchronous sidebar-docking reconciliation script.
104     *
105     * Only emitted when the docked shell was pre-rendered. Because we optimistically
106     * inject the docked sidebar body classes, this script reconciles the gates that
107     * the React hook applies and removes those classes so the chat floats instead.
108     *
109     * The script lives in src/js/sidebar-docking-gate.js and is inlined (not
110     * referenced via `src`) on purpose: it must run render-blocking before paint,
111     * and a same- or cross-origin fetch would add latency to that blocking window.
112     * Reading the bundled file and printing it inline keeps it a real, lintable JS
113     * file with zero request cost.
114     *
115     * @return void
116     */
117    public function print_sidebar_docking_gate_script() {
118        if ( ! $this->should_pre_render_docked_shell() ) {
119            return;
120        }
121
122        global $wp_filesystem;
123
124        if ( empty( $wp_filesystem ) ) {
125            require_once ABSPATH . 'wp-admin/includes/file.php';
126            WP_Filesystem();
127        }
128
129        $script_path = __DIR__ . '/../build/sidebar-docking-gate.js';
130
131        if ( empty( $wp_filesystem ) || ! $wp_filesystem->exists( $script_path ) ) {
132            return;
133        }
134
135        $script = $wp_filesystem->get_contents( $script_path );
136        if ( ! is_string( $script ) || '' === $script ) {
137            return;
138        }
139
140        wp_print_inline_script_tag( $script );
141    }
142
143    /**
144     * Whether the docked-open shell should be pre-rendered on this request.
145     *
146     * True only when the app is loading (so the shell will be reconciled by the
147     * app that mounts to manage it) and the cached state is both open and docked
148     * — the only state that reshapes the admin layout. A cold session (no cache),
149     * a closed sidebar, or a floating (undocked) chat all pre-render nothing.
150     *
151     * @return bool
152     */
153    private function should_pre_render_docked_shell(): bool {
154        if ( ! $this->should_preserve_sidebar_open_state() ) {
155            return false;
156        }
157
158        $state = Open_State_Store::get_cached();
159
160        return $state && true === $state['agents_manager_open'] && true === $state['agents_manager_docked'];
161    }
162
163    /**
164     * Whether sidebar open preservation should run for this request.
165     *
166     * Gated on the same decision that loads the app (its active variant), so the
167     * pre-rendered shell only appears where the app will mount to reconcile it.
168     *
169     * @return bool
170     */
171    private function should_preserve_sidebar_open_state(): bool {
172        return null !== Agents_Manager::get_active_variant();
173    }
174}